• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lightningnetwork / lnd / 15019837305

14 May 2025 11:45AM UTC coverage: 68.91%. First build
15019837305

Pull #9800

github

web-flow
Merge 8101f9550 into b7e72b2ef
Pull Request #9800: multi: various test preparations for different graph store impl

22 of 42 new or added lines in 7 files covered. (52.38%)

133798 of 194164 relevant lines covered (68.91%)

22078.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.13
/graph/db/graph.go
1
package graphdb
2

3
import (
4
        "errors"
5
        "fmt"
6
        "sync"
7
        "sync/atomic"
8
        "testing"
9
        "time"
10

11
        "github.com/btcsuite/btcd/chaincfg/chainhash"
12
        "github.com/btcsuite/btcd/wire"
13
        "github.com/lightningnetwork/lnd/batch"
14
        "github.com/lightningnetwork/lnd/graph/db/models"
15
        "github.com/lightningnetwork/lnd/lnwire"
16
        "github.com/lightningnetwork/lnd/routing/route"
17
        "github.com/stretchr/testify/require"
18
)
19

20
// ErrChanGraphShuttingDown indicates that the ChannelGraph has shutdown or is
21
// busy shutting down.
22
var ErrChanGraphShuttingDown = fmt.Errorf("ChannelGraph shutting down")
23

24
// ChannelGraph is a layer above the graph's CRUD layer.
25
//
26
// NOTE: currently, this is purely a pass-through layer directly to the backing
27
// KVStore. Upcoming commits will move the graph cache out of the KVStore and
28
// into this layer so that the KVStore is only responsible for CRUD operations.
29
type ChannelGraph struct {
30
        started atomic.Bool
31
        stopped atomic.Bool
32

33
        graphCache *GraphCache
34

35
        V1Store
36
        *topologyManager
37

38
        quit chan struct{}
39
        wg   sync.WaitGroup
40
}
41

42
// NewChannelGraph creates a new ChannelGraph instance with the given backend.
43
func NewChannelGraph(v1Store V1Store,
44
        options ...ChanGraphOption) (*ChannelGraph, error) {
172✔
45

172✔
46
        opts := defaultChanGraphOptions()
172✔
47
        for _, o := range options {
265✔
48
                o(opts)
93✔
49
        }
93✔
50

51
        g := &ChannelGraph{
172✔
52
                V1Store:         v1Store,
172✔
53
                topologyManager: newTopologyManager(),
172✔
54
                quit:            make(chan struct{}),
172✔
55
        }
172✔
56

172✔
57
        // The graph cache can be turned off (e.g. for mobile users) for a
172✔
58
        // speed/memory usage tradeoff.
172✔
59
        if opts.useGraphCache {
311✔
60
                g.graphCache = NewGraphCache(opts.preAllocCacheNumNodes)
139✔
61
        }
139✔
62

63
        return g, nil
172✔
64
}
65

66
// Start kicks off any goroutines required for the ChannelGraph to function.
67
// If the graph cache is enabled, then it will be populated with the contents of
68
// the database.
69
func (c *ChannelGraph) Start() error {
285✔
70
        if !c.started.CompareAndSwap(false, true) {
398✔
71
                return nil
113✔
72
        }
113✔
73
        log.Debugf("ChannelGraph starting")
172✔
74
        defer log.Debug("ChannelGraph started")
172✔
75

172✔
76
        if c.graphCache != nil {
311✔
77
                if err := c.populateCache(); err != nil {
139✔
78
                        return fmt.Errorf("could not populate the graph "+
×
79
                                "cache: %w", err)
×
80
                }
×
81
        }
82

83
        c.wg.Add(1)
172✔
84
        go c.handleTopologySubscriptions()
172✔
85

172✔
86
        return nil
172✔
87
}
88

89
// Stop signals any active goroutines for a graceful closure.
90
func (c *ChannelGraph) Stop() error {
285✔
91
        if !c.stopped.CompareAndSwap(false, true) {
398✔
92
                return nil
113✔
93
        }
113✔
94

95
        log.Debugf("ChannelGraph shutting down...")
172✔
96
        defer log.Debug("ChannelGraph shutdown complete")
172✔
97

172✔
98
        close(c.quit)
172✔
99
        c.wg.Wait()
172✔
100

172✔
101
        return nil
172✔
102
}
103

104
// handleTopologySubscriptions ensures that topology client subscriptions,
105
// subscription cancellations and topology notifications are handled
106
// synchronously.
107
//
108
// NOTE: this MUST be run in a goroutine.
109
func (c *ChannelGraph) handleTopologySubscriptions() {
172✔
110
        defer c.wg.Done()
172✔
111

172✔
112
        for {
5,321✔
113
                select {
5,149✔
114
                // A new fully validated topology update has just arrived.
115
                // We'll notify any registered clients.
116
                case update := <-c.topologyUpdate:
4,974✔
117
                        // TODO(elle): change topology handling to be handled
4,974✔
118
                        // synchronously so that we can guarantee the order of
4,974✔
119
                        // notification delivery.
4,974✔
120
                        c.wg.Add(1)
4,974✔
121
                        go c.handleTopologyUpdate(update)
4,974✔
122

123
                        // TODO(roasbeef): remove all unconnected vertexes
124
                        // after N blocks pass with no corresponding
125
                        // announcements.
126

127
                // A new notification client update has arrived. We're either
128
                // gaining a new client, or cancelling notifications for an
129
                // existing client.
130
                case ntfnUpdate := <-c.ntfnClientUpdates:
7✔
131
                        clientID := ntfnUpdate.clientID
7✔
132

7✔
133
                        if ntfnUpdate.cancel {
10✔
134
                                client, ok := c.topologyClients.LoadAndDelete(
3✔
135
                                        clientID,
3✔
136
                                )
3✔
137
                                if ok {
6✔
138
                                        close(client.exit)
3✔
139
                                        client.wg.Wait()
3✔
140

3✔
141
                                        close(client.ntfnChan)
3✔
142
                                }
3✔
143

144
                                continue
3✔
145
                        }
146

147
                        c.topologyClients.Store(clientID, &topologyClient{
6✔
148
                                ntfnChan: ntfnUpdate.ntfnChan,
6✔
149
                                exit:     make(chan struct{}),
6✔
150
                        })
6✔
151

152
                case <-c.quit:
172✔
153
                        return
172✔
154
                }
155
        }
156
}
157

158
// populateCache loads the entire channel graph into the in-memory graph cache.
159
//
160
// NOTE: This should only be called if the graphCache has been constructed.
161
func (c *ChannelGraph) populateCache() error {
139✔
162
        startTime := time.Now()
139✔
163
        log.Info("Populating in-memory channel graph, this might take a " +
139✔
164
                "while...")
139✔
165

139✔
166
        err := c.V1Store.ForEachNodeCacheable(func(node route.Vertex,
139✔
167
                features *lnwire.FeatureVector) error {
241✔
168

102✔
169
                c.graphCache.AddNodeFeatures(node, features)
102✔
170

102✔
171
                return nil
102✔
172
        })
102✔
173
        if err != nil {
139✔
174
                return err
×
175
        }
×
176

177
        err = c.V1Store.ForEachChannel(func(info *models.ChannelEdgeInfo,
139✔
178
                policy1, policy2 *models.ChannelEdgePolicy) error {
537✔
179

398✔
180
                c.graphCache.AddChannel(info, policy1, policy2)
398✔
181

398✔
182
                return nil
398✔
183
        })
398✔
184
        if err != nil {
139✔
185
                return err
×
186
        }
×
187

188
        log.Infof("Finished populating in-memory channel graph (took %v, %s)",
139✔
189
                time.Since(startTime), c.graphCache.Stats())
139✔
190

139✔
191
        return nil
139✔
192
}
193

194
// ForEachNodeDirectedChannel iterates through all channels of a given node,
195
// executing the passed callback on the directed edge representing the channel
196
// and its incoming policy. If the callback returns an error, then the iteration
197
// is halted with the error propagated back up to the caller. If the graphCache
198
// is available, then it will be used to retrieve the node's channels instead
199
// of the database.
200
//
201
// Unknown policies are passed into the callback as nil values.
202
//
203
// NOTE: this is part of the graphdb.NodeTraverser interface.
204
func (c *ChannelGraph) ForEachNodeDirectedChannel(node route.Vertex,
205
        cb func(channel *DirectedChannel) error) error {
486✔
206

486✔
207
        if c.graphCache != nil {
969✔
208
                return c.graphCache.ForEachChannel(node, cb)
483✔
209
        }
483✔
210

211
        return c.V1Store.ForEachNodeDirectedChannel(node, cb)
5✔
212
}
213

214
// FetchNodeFeatures returns the features of the given node. If no features are
215
// known for the node, an empty feature vector is returned.
216
// If the graphCache is available, then it will be used to retrieve the node's
217
// features instead of the database.
218
//
219
// NOTE: this is part of the graphdb.NodeTraverser interface.
220
func (c *ChannelGraph) FetchNodeFeatures(node route.Vertex) (
221
        *lnwire.FeatureVector, error) {
456✔
222

456✔
223
        if c.graphCache != nil {
912✔
224
                return c.graphCache.GetFeatures(node), nil
456✔
225
        }
456✔
226

227
        return c.V1Store.FetchNodeFeatures(node)
2✔
228
}
229

230
// GraphSession will provide the call-back with access to a NodeTraverser
231
// instance which can be used to perform queries against the channel graph. If
232
// the graph cache is not enabled, then the call-back will be provided with
233
// access to the graph via a consistent read-only transaction.
234
func (c *ChannelGraph) GraphSession(cb func(graph NodeTraverser) error) error {
135✔
235
        if c.graphCache != nil {
216✔
236
                return cb(c)
81✔
237
        }
81✔
238

239
        return c.V1Store.GraphSession(cb)
54✔
240
}
241

242
// ForEachNodeCached iterates through all the stored vertices/nodes in the
243
// graph, executing the passed callback with each node encountered.
244
//
245
// NOTE: The callback contents MUST not be modified.
246
func (c *ChannelGraph) ForEachNodeCached(cb func(node route.Vertex,
247
        chans map[uint64]*DirectedChannel) error) error {
1✔
248

1✔
249
        if c.graphCache != nil {
1✔
250
                return c.graphCache.ForEachNode(cb)
×
251
        }
×
252

253
        return c.V1Store.ForEachNodeCached(cb)
1✔
254
}
255

256
// AddLightningNode adds a vertex/node to the graph database. If the node is not
257
// in the database from before, this will add a new, unconnected one to the
258
// graph. If it is present from before, this will update that node's
259
// information. Note that this method is expected to only be called to update an
260
// already present node from a node announcement, or to insert a node found in a
261
// channel update.
262
func (c *ChannelGraph) AddLightningNode(node *models.LightningNode,
263
        op ...batch.SchedulerOption) error {
801✔
264

801✔
265
        err := c.V1Store.AddLightningNode(node, op...)
801✔
266
        if err != nil {
801✔
267
                return err
×
268
        }
×
269

270
        if c.graphCache != nil {
1,415✔
271
                c.graphCache.AddNodeFeatures(
614✔
272
                        node.PubKeyBytes, node.Features,
614✔
273
                )
614✔
274
        }
614✔
275

276
        select {
801✔
277
        case c.topologyUpdate <- node:
801✔
278
        case <-c.quit:
×
279
                return ErrChanGraphShuttingDown
×
280
        }
281

282
        return nil
801✔
283
}
284

285
// DeleteLightningNode starts a new database transaction to remove a vertex/node
286
// from the database according to the node's public key.
287
func (c *ChannelGraph) DeleteLightningNode(nodePub route.Vertex) error {
3✔
288
        err := c.V1Store.DeleteLightningNode(nodePub)
3✔
289
        if err != nil {
3✔
290
                return err
×
291
        }
×
292

293
        if c.graphCache != nil {
6✔
294
                c.graphCache.RemoveNode(nodePub)
3✔
295
        }
3✔
296

297
        return nil
3✔
298
}
299

300
// AddChannelEdge adds a new (undirected, blank) edge to the graph database. An
301
// undirected edge from the two target nodes are created. The information stored
302
// denotes the static attributes of the channel, such as the channelID, the keys
303
// involved in creation of the channel, and the set of features that the channel
304
// supports. The chanPoint and chanID are used to uniquely identify the edge
305
// globally within the database.
306
func (c *ChannelGraph) AddChannelEdge(edge *models.ChannelEdgeInfo,
307
        op ...batch.SchedulerOption) error {
1,728✔
308

1,728✔
309
        err := c.V1Store.AddChannelEdge(edge, op...)
1,728✔
310
        if err != nil {
1,963✔
311
                return err
235✔
312
        }
235✔
313

314
        if c.graphCache != nil {
2,796✔
315
                c.graphCache.AddChannel(edge, nil, nil)
1,303✔
316
        }
1,303✔
317

318
        select {
1,493✔
319
        case c.topologyUpdate <- edge:
1,493✔
320
        case <-c.quit:
×
321
                return ErrChanGraphShuttingDown
×
322
        }
323

324
        return nil
1,493✔
325
}
326

327
// MarkEdgeLive clears an edge from our zombie index, deeming it as live.
328
// If the cache is enabled, the edge will be added back to the graph cache if
329
// we still have a record of this channel in the DB.
330
func (c *ChannelGraph) MarkEdgeLive(chanID uint64) error {
2✔
331
        err := c.V1Store.MarkEdgeLive(chanID)
2✔
332
        if err != nil {
3✔
333
                return err
1✔
334
        }
1✔
335

336
        if c.graphCache != nil {
2✔
337
                // We need to add the channel back into our graph cache,
1✔
338
                // otherwise we won't use it for path finding.
1✔
339
                infos, err := c.V1Store.FetchChanInfos([]uint64{chanID})
1✔
340
                if err != nil {
1✔
341
                        return err
×
342
                }
×
343

344
                if len(infos) == 0 {
2✔
345
                        return nil
1✔
346
                }
1✔
347

348
                info := infos[0]
×
349

×
350
                c.graphCache.AddChannel(info.Info, info.Policy1, info.Policy2)
×
351
        }
352

353
        return nil
×
354
}
355

356
// DeleteChannelEdges removes edges with the given channel IDs from the
357
// database and marks them as zombies. This ensures that we're unable to re-add
358
// it to our database once again. If an edge does not exist within the
359
// database, then ErrEdgeNotFound will be returned. If strictZombiePruning is
360
// true, then when we mark these edges as zombies, we'll set up the keys such
361
// that we require the node that failed to send the fresh update to be the one
362
// that resurrects the channel from its zombie state. The markZombie bool
363
// denotes whether to mark the channel as a zombie.
364
func (c *ChannelGraph) DeleteChannelEdges(strictZombiePruning, markZombie bool,
365
        chanIDs ...uint64) error {
149✔
366

149✔
367
        infos, err := c.V1Store.DeleteChannelEdges(
149✔
368
                strictZombiePruning, markZombie, chanIDs...,
149✔
369
        )
149✔
370
        if err != nil {
219✔
371
                return err
70✔
372
        }
70✔
373

374
        if c.graphCache != nil {
158✔
375
                for _, info := range infos {
103✔
376
                        c.graphCache.RemoveChannel(
24✔
377
                                info.NodeKey1Bytes, info.NodeKey2Bytes,
24✔
378
                                info.ChannelID,
24✔
379
                        )
24✔
380
                }
24✔
381
        }
382

383
        return err
79✔
384
}
385

386
// DisconnectBlockAtHeight is used to indicate that the block specified
387
// by the passed height has been disconnected from the main chain. This
388
// will "rewind" the graph back to the height below, deleting channels
389
// that are no longer confirmed from the graph. The prune log will be
390
// set to the last prune height valid for the remaining chain.
391
// Channels that were removed from the graph resulting from the
392
// disconnected block are returned.
393
func (c *ChannelGraph) DisconnectBlockAtHeight(height uint32) (
394
        []*models.ChannelEdgeInfo, error) {
156✔
395

156✔
396
        edges, err := c.V1Store.DisconnectBlockAtHeight(height)
156✔
397
        if err != nil {
156✔
398
                return nil, err
×
399
        }
×
400

401
        if c.graphCache != nil {
312✔
402
                for _, edge := range edges {
257✔
403
                        c.graphCache.RemoveChannel(
101✔
404
                                edge.NodeKey1Bytes, edge.NodeKey2Bytes,
101✔
405
                                edge.ChannelID,
101✔
406
                        )
101✔
407
                }
101✔
408
        }
409

410
        return edges, nil
156✔
411
}
412

413
// PruneGraph prunes newly closed channels from the channel graph in response
414
// to a new block being solved on the network. Any transactions which spend the
415
// funding output of any known channels within he graph will be deleted.
416
// Additionally, the "prune tip", or the last block which has been used to
417
// prune the graph is stored so callers can ensure the graph is fully in sync
418
// with the current UTXO state. A slice of channels that have been closed by
419
// the target block are returned if the function succeeds without error.
420
func (c *ChannelGraph) PruneGraph(spentOutputs []*wire.OutPoint,
421
        blockHash *chainhash.Hash, blockHeight uint32) (
422
        []*models.ChannelEdgeInfo, error) {
239✔
423

239✔
424
        edges, nodes, err := c.V1Store.PruneGraph(
239✔
425
                spentOutputs, blockHash, blockHeight,
239✔
426
        )
239✔
427
        if err != nil {
239✔
428
                return nil, err
×
429
        }
×
430

431
        if c.graphCache != nil {
478✔
432
                for _, edge := range edges {
262✔
433
                        c.graphCache.RemoveChannel(
23✔
434
                                edge.NodeKey1Bytes, edge.NodeKey2Bytes,
23✔
435
                                edge.ChannelID,
23✔
436
                        )
23✔
437
                }
23✔
438

439
                for _, node := range nodes {
295✔
440
                        c.graphCache.RemoveNode(node)
56✔
441
                }
56✔
442

443
                log.Debugf("Pruned graph, cache now has %s",
239✔
444
                        c.graphCache.Stats())
239✔
445
        }
446

447
        if len(edges) != 0 {
260✔
448
                // Notify all currently registered clients of the newly closed
21✔
449
                // channels.
21✔
450
                closeSummaries := createCloseSummaries(
21✔
451
                        blockHeight, edges...,
21✔
452
                )
21✔
453

21✔
454
                select {
21✔
455
                case c.topologyUpdate <- closeSummaries:
21✔
456
                case <-c.quit:
×
457
                        return nil, ErrChanGraphShuttingDown
×
458
                }
459
        }
460

461
        return edges, nil
239✔
462
}
463

464
// PruneGraphNodes is a garbage collection method which attempts to prune out
465
// any nodes from the channel graph that are currently unconnected. This ensure
466
// that we only maintain a graph of reachable nodes. In the event that a pruned
467
// node gains more channels, it will be re-added back to the graph.
468
func (c *ChannelGraph) PruneGraphNodes() error {
25✔
469
        nodes, err := c.V1Store.PruneGraphNodes()
25✔
470
        if err != nil {
25✔
471
                return err
×
472
        }
×
473

474
        if c.graphCache != nil {
50✔
475
                for _, node := range nodes {
32✔
476
                        c.graphCache.RemoveNode(node)
7✔
477
                }
7✔
478
        }
479

480
        return nil
25✔
481
}
482

483
// FilterKnownChanIDs takes a set of channel IDs and return the subset of chan
484
// ID's that we don't know and are not known zombies of the passed set. In other
485
// words, we perform a set difference of our set of chan ID's and the ones
486
// passed in. This method can be used by callers to determine the set of
487
// channels another peer knows of that we don't.
488
func (c *ChannelGraph) FilterKnownChanIDs(chansInfo []ChannelUpdateInfo,
489
        isZombieChan func(time.Time, time.Time) bool) ([]uint64, error) {
128✔
490

128✔
491
        unknown, knownZombies, err := c.V1Store.FilterKnownChanIDs(chansInfo)
128✔
492
        if err != nil {
128✔
493
                return nil, err
×
494
        }
×
495

496
        for _, info := range knownZombies {
177✔
497
                // TODO(ziggie): Make sure that for the strict pruning case we
49✔
498
                // compare the pubkeys and whether the right timestamp is not
49✔
499
                // older than the `ChannelPruneExpiry`.
49✔
500
                //
49✔
501
                // NOTE: The timestamp data has no verification attached to it
49✔
502
                // in the `ReplyChannelRange` msg so we are trusting this data
49✔
503
                // at this point. However it is not critical because we are just
49✔
504
                // removing the channel from the db when the timestamps are more
49✔
505
                // recent. During the querying of the gossip msg verification
49✔
506
                // happens as usual. However we should start punishing peers
49✔
507
                // when they don't provide us honest data ?
49✔
508
                isStillZombie := isZombieChan(
49✔
509
                        info.Node1UpdateTimestamp, info.Node2UpdateTimestamp,
49✔
510
                )
49✔
511

49✔
512
                if isStillZombie {
77✔
513
                        continue
28✔
514
                }
515

516
                // If we have marked it as a zombie but the latest update
517
                // timestamps could bring it back from the dead, then we mark it
518
                // alive, and we let it be added to the set of IDs to query our
519
                // peer for.
520
                err := c.V1Store.MarkEdgeLive(
21✔
521
                        info.ShortChannelID.ToUint64(),
21✔
522
                )
21✔
523
                // Since there is a chance that the edge could have been marked
21✔
524
                // as "live" between the FilterKnownChanIDs call and the
21✔
525
                // MarkEdgeLive call, we ignore the error if the edge is already
21✔
526
                // marked as live.
21✔
527
                if err != nil && !errors.Is(err, ErrZombieEdgeNotFound) {
21✔
528
                        return nil, err
×
529
                }
×
530
        }
531

532
        return unknown, nil
128✔
533
}
534

535
// MarkEdgeZombie attempts to mark a channel identified by its channel ID as a
536
// zombie. This method is used on an ad-hoc basis, when channels need to be
537
// marked as zombies outside the normal pruning cycle.
538
func (c *ChannelGraph) MarkEdgeZombie(chanID uint64,
539
        pubKey1, pubKey2 [33]byte) error {
126✔
540

126✔
541
        err := c.V1Store.MarkEdgeZombie(chanID, pubKey1, pubKey2)
126✔
542
        if err != nil {
126✔
543
                return err
×
544
        }
×
545

546
        if c.graphCache != nil {
252✔
547
                c.graphCache.RemoveChannel(pubKey1, pubKey2, chanID)
126✔
548
        }
126✔
549

550
        return nil
126✔
551
}
552

553
// UpdateEdgePolicy updates the edge routing policy for a single directed edge
554
// within the database for the referenced channel. The `flags` attribute within
555
// the ChannelEdgePolicy determines which of the directed edges are being
556
// updated. If the flag is 1, then the first node's information is being
557
// updated, otherwise it's the second node's information. The node ordering is
558
// determined by the lexicographical ordering of the identity public keys of the
559
// nodes on either side of the channel.
560
func (c *ChannelGraph) UpdateEdgePolicy(edge *models.ChannelEdgePolicy,
561
        op ...batch.SchedulerOption) error {
2,668✔
562

2,668✔
563
        from, to, err := c.V1Store.UpdateEdgePolicy(edge, op...)
2,668✔
564
        if err != nil {
2,671✔
565
                return err
3✔
566
        }
3✔
567

568
        if c.graphCache != nil {
4,944✔
569
                var isUpdate1 bool
2,279✔
570
                if edge.ChannelFlags&lnwire.ChanUpdateDirection == 0 {
3,423✔
571
                        isUpdate1 = true
1,144✔
572
                }
1,144✔
573

574
                c.graphCache.UpdatePolicy(edge, from, to, isUpdate1)
2,279✔
575
        }
576

577
        select {
2,665✔
578
        case c.topologyUpdate <- edge:
2,665✔
579
        case <-c.quit:
×
580
                return ErrChanGraphShuttingDown
×
581
        }
582

583
        return nil
2,665✔
584
}
585

586
// MakeTestGraphNew creates a new instance of the ChannelGraph for testing
587
// purposes. The backing V1Store implementation depends on the version of
588
// NewTestDB included in the current build.
589
//
590
// NOTE: this is currently unused, but is left here for future use to show how
591
// NewTestDB can be used. As the SQL implementation of the V1Store is
592
// implemented, unit tests will be switched to use this function instead of
593
// the existing MakeTestGraph helper. Once only this function is used, the
594
// existing MakeTestGraph function will be removed and this one will be renamed.
595
func MakeTestGraphNew(t testing.TB,
NEW
596
        opts ...ChanGraphOption) *ChannelGraph {
×
NEW
597

×
NEW
598
        t.Helper()
×
NEW
599

×
NEW
600
        store := NewTestDB(t)
×
NEW
601

×
NEW
602
        graph, err := NewChannelGraph(store, opts...)
×
NEW
603
        require.NoError(t, err)
×
NEW
604
        require.NoError(t, graph.Start())
×
NEW
605

×
NEW
606
        t.Cleanup(func() {
×
NEW
607
                require.NoError(t, graph.Stop())
×
NEW
608
        })
×
609

NEW
610
        return graph
×
611
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc