Artifact 2c34eadd410c711f5f8d9c1f8aa48cbb805899817fea6c6ec9cf222b09ca9f9f:
- File
internal/manifest/dephantomize_cascade_test.go
— part of check-in
[bfb2ad3aa4]
at
2026-07-26 13:15:53
on branch trunk
— perf: batch the phantom-fill crosslink cascade onto a shared content cache (#172) (#175)
afterDephantomize crosslinked every artifact it discovered through crosslinkSingle, which opened its own write transaction per artifact and passed an explicit nil content cache into crosslinkCheckin. Both choices suit an isolated artifact and neither suits the cascade: one phantom fill unblocks hundreds or thousands of related artifacts that share delta bases and parent manifests, so the walk paid a transaction commit per artifact and re-expanded content a sibling had just expanded. A CPU profile of round 7 of a fossil-corpus clone showed exactly that pair -- _vdbeCommit plus expandChain/decompress reached through crosslinkSingle -- as the remaining 300+ seconds after the statement-cache fix.
crosslinkSingle had no callers outside the cascade, so it is replaced rather than kept alongside a second strategy. cascadeLinker buffers the artifacts the walk discovers and links them through linkArtifact -- the same type switch the whole-repository sweep uses, so the two paths no longer carry near-duplicate switches -- on one content.Cache for the whole cascade, committing once per crosslinkBatchSize artifacts instead of once per artifact. The sweep's own batching, cache, and tuning constants are untouched.
The walk's delta-child query filters on committed mlink rows, so deferring writes past it could change which artifacts it visits. Orphans are the only discovery that query can observe -- delta.rid is a primary key, so every other buffered artifact was itself produced by that query and cannot come back -- so the buffer flushes after an orphan batch and before the query, preserving the original order: orphans crosslinked, orphan rows cleared, children queried.
Failure handling keeps what per-artifact transactions bought. An artifact that cannot be expanded, parsed, or linked is logged with the same wording and skipped while the rest of the batch commits, and each artifact's writes are wrapped in a savepoint so a link that fails partway leaves no partial event/plink/mlink/tagxref behind. Cancellation is unchanged in the walk and newly observed inside a flush on the sweep's stride; an open batch is abandoned rather than committed, so committed batches stand and the next Crosslink sweep re-selects whatever still has no event row.
Tests: a characterization test pins the cascade's derived rows and visit count over a graph covering both sides of the mlink filter -- an orphan that writes mlink rows and one that does not, and so is still visited twice -- against the dump the per-artifact shape produced. A regression guard asserts one commit and one expansion per artifact with the parent manifest served from cache for a 300-artifact burst, plus tests for whole-batch flushing, savepoint isolation, and mid-flush cancellation. (user: [email protected], size: 21670) [annotate] [blame] [check-ins using]
package manifest import ( "context" "fmt" "sort" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // buildCascadeGraph stores the artifact graph one phantom fill unblocks, in the // shapes the walk actually distinguishes: // // rid 1 file blob the F-card target every manifest names // rid 2 root checkin the filled phantom the cascade starts from // rid 3 child checkin delta of root, P-card -> root (parent-manifest lookup) // rid 4 child checkin delta of root, P-card -> root, renames the file // rid 5 wiki manifest delta of root, the non-checkin branch of linkArtifact // rid 6 plain blob delta of root, not a manifest: the parse-failure branch // rid 7 orphan checkin delta of root AND an orphan row keyed on root // rid 8 orphan checkin same, but renames the file so it writes mlink rows // // rids 7 and 8 are the read-after-write case, and they land on opposite sides // of it. The walk crosslinks both as root's orphans and then asks which delta // children of root still lack mlink rows. rid 8's crosslink wrote mlink rows, // so under per-artifact transactions -- committed by the time the query ran -- // the query filtered rid 8 out and the walk visited it once. rid 7 carries its // file over unchanged and so writes no mlink row at all, so the query did NOT // filter it and the walk visited it a second time, idempotently. Anything that // defers those writes past the query has to reproduce both halves exactly. // // Rids are spelled out because a fresh test repository assigns them in store // order, which is what makes the golden dump below readable. func buildCascadeGraph(t *testing.T, r *repo.Repo) libfossil.FslID { t.Helper() fileRid, fileUUID, err := blob.Store(r.DB(), []byte("cascade file content")) if err != nil { t.Fatalf("Store file blob: %v", err) } if fileRid != 1 { t.Fatalf("file blob rid = %d, want 1 (fixture assumes a fresh repository)", fileRid) } at := func(min int) time.Time { return time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC).Add(time.Duration(min) * time.Minute) } marshal := func(d *deck.Deck) []byte { mb, err := d.Marshal() if err != nil { t.Fatalf("Marshal %v: %v", d.Type, err) } return mb } rootRid, rootUUID, err := blob.Store(r.DB(), marshal(&deck.Deck{ Type: deck.Checkin, C: "cascade root", U: deck.User("testuser"), D: at(0), F: []deck.FileCard{{Name: "hello.txt", UUID: fileUUID}}, })) if err != nil { t.Fatalf("Store root checkin: %v", err) } childBytes := marshal(&deck.Deck{ Type: deck.Checkin, C: "cascade child", U: deck.User("testuser"), D: at(1), P: []string{rootUUID}, F: []deck.FileCard{{Name: "hello.txt", UUID: fileUUID}}, }) if _, _, err := blob.StoreDelta(r.DB(), childBytes, rootRid); err != nil { t.Fatalf("StoreDelta child checkin: %v", err) } renameBytes := marshal(&deck.Deck{ Type: deck.Checkin, C: "cascade rename", U: deck.User("testuser"), D: at(2), P: []string{rootUUID}, F: []deck.FileCard{{Name: "goodbye.txt", UUID: fileUUID, OldName: "hello.txt"}}, }) if _, _, err := blob.StoreDelta(r.DB(), renameBytes, rootRid); err != nil { t.Fatalf("StoreDelta rename checkin: %v", err) } wikiBytes := marshal(&deck.Deck{ Type: deck.Wiki, L: "CascadePage", U: deck.User("testuser"), D: at(3), W: []byte("cascade wiki body"), }) if _, _, err := blob.StoreDelta(r.DB(), wikiBytes, rootRid); err != nil { t.Fatalf("StoreDelta wiki: %v", err) } if _, _, err := blob.StoreDelta(r.DB(), []byte("not a manifest at all"), rootRid); err != nil { t.Fatalf("StoreDelta plain blob: %v", err) } orphanBytes := marshal(&deck.Deck{ Type: deck.Checkin, C: "cascade orphan", U: deck.User("testuser"), D: at(4), P: []string{rootUUID}, F: []deck.FileCard{{Name: "hello.txt", UUID: fileUUID}}, }) orphanRid, _, err := blob.StoreDelta(r.DB(), orphanBytes, rootRid) if err != nil { t.Fatalf("StoreDelta orphan checkin: %v", err) } if _, err := r.DB().Exec("INSERT INTO orphan(rid, baseline) VALUES(?, ?)", orphanRid, rootRid); err != nil { t.Fatalf("insert orphan row: %v", err) } mlinkOrphanBytes := marshal(&deck.Deck{ Type: deck.Checkin, C: "cascade orphan rename", U: deck.User("testuser"), D: at(5), P: []string{rootUUID}, F: []deck.FileCard{{Name: "orphan.txt", UUID: fileUUID, OldName: "hello.txt"}}, }) mlinkOrphanRid, _, err := blob.StoreDelta(r.DB(), mlinkOrphanBytes, rootRid) if err != nil { t.Fatalf("StoreDelta mlink orphan checkin: %v", err) } if _, err := r.DB().Exec("INSERT INTO orphan(rid, baseline) VALUES(?, ?)", mlinkOrphanRid, rootRid); err != nil { t.Fatalf("insert mlink orphan row: %v", err) } for _, tbl := range []string{ "DELETE FROM event", "DELETE FROM plink", "DELETE FROM mlink", "DELETE FROM tagxref", "DELETE FROM leaf", } { if _, err := r.DB().Exec(tbl); err != nil { t.Fatalf("%s: %v", tbl, err) } } return rootRid } // dumpDerivedRows renders every derived row the cascade can write, in a stable // order, as one comparable string. func dumpDerivedRows(t *testing.T, r *repo.Repo) string { t.Helper() queries := []struct { table string sql string }{ {"event", "SELECT type, objid, user, comment FROM event ORDER BY objid, type"}, {"plink", "SELECT pid, cid, isprim, baseid FROM plink ORDER BY cid, pid"}, {"mlink", "SELECT mid, fid, pmid, pid, fnid, pfnid, mperm, isaux FROM mlink ORDER BY mid, fnid, fid"}, {"tagxref", "SELECT tagid, tagtype, srcid, value, rid FROM tagxref ORDER BY rid, tagid"}, {"filename", "SELECT fnid, name FROM filename ORDER BY fnid"}, {"orphan", "SELECT rid, baseline FROM orphan ORDER BY rid"}, } var out strings.Builder for _, q := range queries { rows, err := r.DB().Query(q.sql) if err != nil { t.Fatalf("dump %s: %v", q.table, err) } cols, err := rows.Columns() if err != nil { rows.Close() t.Fatalf("dump %s columns: %v", q.table, err) } var lines []string for rows.Next() { vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { rows.Close() t.Fatalf("dump %s scan: %v", q.table, err) } parts := make([]string, len(cols)) for i, v := range vals { switch tv := v.(type) { case nil: parts[i] = cols[i] + "=NULL" case []byte: parts[i] = cols[i] + "=" + string(tv) case bool: // The two SQLite drivers disagree on how a boolean column // scans into an any: ncruces yields a Go bool where modernc // yields an int64. The stored value is identical either way, // so normalize to SQLite's own 1/0 and keep this golden // driver-independent rather than pinning one driver's // rendering (isprim, isaux). if tv { parts[i] = cols[i] + "=1" } else { parts[i] = cols[i] + "=0" } default: parts[i] = fmt.Sprintf("%s=%v", cols[i], tv) } } lines = append(lines, q.table+" "+strings.Join(parts, " ")) } if err := rows.Err(); err != nil { rows.Close() t.Fatalf("dump %s rows: %v", q.table, err) } rows.Close() sort.Strings(lines) for _, l := range lines { out.WriteString(l) out.WriteString("\n") } } return out.String() } // cascadeGolden is the derived state buildCascadeGraph's cascade produced under // the per-artifact-transaction shape this package used before the cascade was // batched. It is a characterization: it passed before that change and must keep // passing after, because batching commits and sharing a content cache is a // change to how the work runs, never to what it writes. // The filename rows are load-bearing: fnids are handed out in the order the // walk crosslinks artifacts, so orphan.txt landing on fnid 2 and goodbye.txt on // fnid 3 pins that rid 8 is still linked before rid 4. A cascade that reordered // or dropped a visit would renumber them. const cascadeGolden = `event type=ci objid=2 user=testuser comment=cascade root event type=ci objid=3 user=testuser comment=cascade child event type=ci objid=4 user=testuser comment=cascade rename event type=ci objid=7 user=testuser comment=cascade orphan event type=ci objid=8 user=testuser comment=cascade orphan rename event type=w objid=5 user=testuser comment=+CascadePage plink pid=2 cid=3 isprim=1 baseid=NULL plink pid=2 cid=4 isprim=1 baseid=NULL plink pid=2 cid=7 isprim=1 baseid=NULL plink pid=2 cid=8 isprim=1 baseid=NULL mlink mid=2 fid=1 pmid=0 pid=0 fnid=1 pfnid=0 mperm=0 isaux=0 mlink mid=4 fid=0 pmid=2 pid=1 fnid=1 pfnid=0 mperm=0 isaux=0 mlink mid=4 fid=1 pmid=2 pid=1 fnid=3 pfnid=1 mperm=0 isaux=0 mlink mid=8 fid=0 pmid=2 pid=1 fnid=1 pfnid=0 mperm=0 isaux=0 mlink mid=8 fid=1 pmid=2 pid=1 fnid=2 pfnid=1 mperm=0 isaux=0 tagxref tagid=12 tagtype=1 srcid=5 value=17 rid=5 filename fnid=1 name=hello.txt filename fnid=2 name=orphan.txt filename fnid=3 name=goodbye.txt ` // cascadeVisits is how many artifacts the walk over buildCascadeGraph hands to // the crosslink path: root, both orphans, then the four delta children of root // the child query still returns, and rid 7 a second time as one of them. It was // measured against the per-artifact-transaction shape (visit order // [2 7 8 3 4 5 6 7]) and is asserted rather than derived so that a batching bug // in the pending-mlink filter shows up as a count change even where the derived // rows themselves are idempotent: mis-filtering rid 8 makes it 9, over-filtering // rid 7 makes it 7. const cascadeVisits = 8 func TestAfterDephantomizeCascadeDerivedRowsUnchanged(t *testing.T) { r := setupTestRepo(t) root := buildCascadeGraph(t, r) stats := afterDephantomize(context.Background(), r, root, true) got := dumpDerivedRows(t, r) if got != cascadeGolden { t.Errorf("cascade derived rows changed.\n--- got ---\n%s\n--- want ---\n%s", got, cascadeGolden) } if stats.attempts != cascadeVisits { t.Errorf("cascade crosslinked %d artifacts, want %d: the set of artifacts the walk "+ "visits changed", stats.attempts, cascadeVisits) } } // buildSharedParentFanout stores a root check-in plus fanout children that are // all delta-encoded against that root AND all name it as their P-card parent. // That is the cascade's real shape: one phantom fill unblocks a burst of related // artifacts that share a delta base and a parent manifest. Crosslinking a child // expands the parent manifest (insertCheckinMlinks -> loadPrimaryParentFiles), // so without a cache shared across the burst the root is expanded once per // child, and with one it is expanded once for the whole cascade. func buildSharedParentFanout(t *testing.T, r *repo.Repo, fanout int) (libfossil.FslID, []libfossil.FslID) { t.Helper() _, fileUUID, err := blob.Store(r.DB(), []byte("shared parent file")) if err != nil { t.Fatalf("Store file blob: %v", err) } marshal := func(d *deck.Deck) []byte { mb, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } return mb } root, rootUUID, err := blob.Store(r.DB(), marshal(&deck.Deck{ Type: deck.Checkin, C: "shared parent root", U: deck.User("testuser"), D: time.Date(2024, 4, 1, 9, 0, 0, 0, time.UTC), F: []deck.FileCard{{Name: "hello.txt", UUID: fileUUID}}, })) if err != nil { t.Fatalf("Store root: %v", err) } children := make([]libfossil.FslID, fanout) for i := range children { body := marshal(&deck.Deck{ Type: deck.Checkin, C: fmt.Sprintf("shared parent child %d", i), U: deck.User("testuser"), D: time.Date(2024, 4, 1, 9, 0, 0, 0, time.UTC).Add(time.Duration(i+1) * time.Minute), P: []string{rootUUID}, F: []deck.FileCard{{Name: fmt.Sprintf("child%d.txt", i), UUID: fileUUID}}, }) rid, _, err := blob.StoreDelta(r.DB(), body, root) if err != nil { t.Fatalf("StoreDelta child %d: %v", i, err) } children[i] = rid } return root, children } // TestAfterDephantomizeCascadeBatchesAndSharesCache is the regression guard for // issue #172: the cascade used to open one write transaction per artifact and // hand crosslinkCheckin a nil content cache, so a burst of related artifacts // paid a commit each and re-expanded the same delta base and parent manifest // over and over. // // The bounds are structural, not timings. fanout+1 artifacts fit in one // crosslinkBatchSize batch, so the whole cascade must cost exactly one commit. // Every artifact's own content is expanded once (fanout+1 expansions) and every // child's parent-manifest lookup must then be a cache hit, because the parent is // the root the cache already holds -- so hits scale with fanout while expansions // do not. Undo either half and this fails loudly: without batching commits // become fanout+1, without a shared cache hits collapse to zero and expansions // roughly double. func TestAfterDephantomizeCascadeBatchesAndSharesCache(t *testing.T) { const fanout = 300 if fanout+1 >= crosslinkBatchSize { t.Fatalf("fixture of %d artifacts no longer fits one batch of %d", fanout+1, crosslinkBatchSize) } r := setupTestRepo(t) root, children := buildSharedParentFanout(t, r, fanout) stats := afterDephantomize(context.Background(), r, root, true) if got := countCrosslinked(t, r, children); got != fanout { t.Fatalf("crosslinked %d of %d children, want all of them", got, fanout) } if stats.attempts != fanout+1 { t.Fatalf("visited %d artifacts, want %d", stats.attempts, fanout+1) } if stats.commits != 1 { t.Errorf("cascade committed %d transactions for %d artifacts, want 1: the batch "+ "buffer is not batching (issue #172)", stats.commits, stats.attempts) } if stats.expansions > fanout+1 { t.Errorf("cascade materialized %d content expansions for %d artifacts, want at most %d: "+ "content shared across the cascade is being re-expanded (issue #172)", stats.expansions, stats.attempts, fanout+1) } if stats.cacheHits < fanout { t.Errorf("cascade served %d expansions from its shared cache, want at least %d: "+ "the parent manifest every child names is not being reused (issue #172)", stats.cacheHits, fanout) } } // TestAfterDephantomizeCascadeIsolatesOneArtifactsFailure pins the failure // contract batching had to preserve. Each artifact used to get its own // transaction, which bought two things: a blob whose crosslink failed halfway // left no partial event/plink/mlink/tagxref behind, and its failure cost the // walk nothing else. Sharing a transaction across a batch threatens both, so // each artifact's writes are wrapped in a savepoint. // // Dropping 'filename' makes a check-in that emits any mlink row fail inside // insertCheckinMlinks -- after crosslinkCheckinTables has already written its // event and plink rows. The wiki ahead of it in the same batch must survive, // and the check-in must leave nothing. func TestAfterDephantomizeCascadeIsolatesOneArtifactsFailure(t *testing.T) { r := setupTestRepo(t) _, fileUUID, err := blob.Store(r.DB(), []byte("isolation file")) if err != nil { t.Fatalf("Store file blob: %v", err) } marshal := func(d *deck.Deck) []byte { mb, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } return mb } wikiRid, _, err := blob.Store(r.DB(), marshal(&deck.Deck{ Type: deck.Wiki, L: "IsolationPage", U: deck.User("testuser"), D: time.Date(2024, 5, 1, 8, 0, 0, 0, time.UTC), W: []byte("isolation wiki body"), })) if err != nil { t.Fatalf("Store wiki root: %v", err) } // Reached as an orphan of the wiki, so both land in the same batch: the // wiki as the popped work item, the check-in right behind it. failRid, _, err := blob.Store(r.DB(), marshal(&deck.Deck{ Type: deck.Checkin, C: "isolation checkin", U: deck.User("testuser"), D: time.Date(2024, 5, 1, 8, 1, 0, 0, time.UTC), F: []deck.FileCard{{Name: "isolation.txt", UUID: fileUUID}}, })) if err != nil { t.Fatalf("Store failing checkin: %v", err) } if _, err := r.DB().Exec("INSERT INTO orphan(rid, baseline) VALUES(?, ?)", failRid, wikiRid); err != nil { t.Fatalf("insert orphan row: %v", err) } if _, err := r.DB().Exec("DROP TABLE filename"); err != nil { t.Fatalf("DROP TABLE filename: %v", err) } stats := afterDephantomize(context.Background(), r, wikiRid, true) if stats.attempts != 2 { t.Fatalf("visited %d artifacts, want 2", stats.attempts) } if stats.commits != 1 { t.Errorf("committed %d transactions, want 1: one artifact's failure took the "+ "batch down with it", stats.commits) } if stats.linked != 1 { t.Errorf("linked %d artifacts, want 1 (the wiki; the check-in fails)", stats.linked) } var wikiEvents int if err := r.DB().QueryRow("SELECT count(*) FROM event WHERE objid=?", wikiRid).Scan(&wikiEvents); err != nil { t.Fatalf("count wiki events: %v", err) } if wikiEvents != 1 { t.Errorf("wiki rid=%d has %d event rows, want 1: a sibling's failure rolled back "+ "work that had already succeeded", wikiRid, wikiEvents) } for _, tbl := range []struct{ name, sql string }{ {"event", "SELECT count(*) FROM event WHERE objid=?"}, {"plink", "SELECT count(*) FROM plink WHERE cid=?"}, {"mlink", "SELECT count(*) FROM mlink WHERE mid=?"}, } { var n int if err := r.DB().QueryRow(tbl.sql, failRid).Scan(&n); err != nil { t.Fatalf("count %s: %v", tbl.name, err) } if n != 0 { t.Errorf("failed check-in rid=%d left %d %s rows, want 0: its partial writes "+ "were committed with the rest of the batch", failRid, n, tbl.name) } } } // TestAfterDephantomizeCascadeCancellationAbandonsOpenBatch pins that the // deadline is still observed once the walk is inside a batch, not only between // pops. A batch flush is the one place the cascade now does a bounded pile of // work in a single call, so it polls ctx on the same crosslinkCancelCheckStride // the sweep's linkBatch uses and abandons the open batch when the poll fires -- // committed batches stand, the open one does not, and nothing pretends the // cascade finished. func TestAfterDephantomizeCascadeCancellationAbandonsOpenBatch(t *testing.T) { r := setupTestRepo(t) root, children := buildSharedParentFanout(t, r, crosslinkBatchSize+10) // Live through every walk poll of the first crosslinkBatchSize pops // (crosslinkBatchSize/crosslinkCancelCheckStride of them) and the flush's // own first poll, then dead: the only check that can observe that is the // one inside the flush. ctx := newPollCancelCtx(crosslinkBatchSize/crosslinkCancelCheckStride + 1) stats := afterDephantomize(ctx, r, root, true) if stats.commits != 0 { t.Errorf("committed %d transactions, want 0: the batch open when the deadline "+ "fired was not abandoned", stats.commits) } if got := countCrosslinked(t, r, children); got != 0 { t.Errorf("crosslinked %d children, want 0: an abandoned batch left rows behind", got) } } // TestAfterDephantomizeCascadeCommitsWholeBatches pins that the buffer really // does flush on crosslinkBatchSize rather than only at the end of the walk: a // cascade wider than one batch must commit more than once, so the WAL bound the // sweep's batching exists to hold (see crosslinkBatchSize) also holds here. func TestAfterDephantomizeCascadeCommitsWholeBatches(t *testing.T) { r := setupTestRepo(t) root, children := buildSharedParentFanout(t, r, crosslinkBatchSize+10) stats := afterDephantomize(context.Background(), r, root, true) if got := countCrosslinked(t, r, children); got != len(children) { t.Fatalf("crosslinked %d of %d children, want all of them", got, len(children)) } if stats.commits != 2 { t.Errorf("cascade committed %d transactions for %d artifacts, want 2 "+ "(one full batch of %d plus the remainder)", stats.commits, stats.attempts, crosslinkBatchSize) } } // TestAfterDephantomizeCascadeAbandonsPartialBatchAtWalkPoll pins that a // deadline firing at a *walk* poll, with artifacts buffered but not yet // flushed, abandons that buffer instead of committing it. // // This is the half of the cancellation contract the other two tests miss. // TestAfterDephantomizeCascadeCancellationAbandonsOpenBatch cancels inside a // flush, where the failed flush has already drained pending, so the follow-up // flush is a no-op either way; TestAfterDephantomizeObservesCancellationMidWalk // asserts only a lower bound, which a partial post-deadline flush still // satisfies. Neither notices if the final flush stops honoring ctx. // // Flushing a full buffer after the deadline fired is exactly the overshoot // issue #166 closed -- up to crosslinkBatchSize crosslinks charged to a clone // that was already out of time -- so it is worth a guard of its own. func TestAfterDephantomizeCascadeAbandonsPartialBatchAtWalkPoll(t *testing.T) { r := setupTestRepo(t) // Fewer than crosslinkBatchSize, so nothing auto-flushes mid-walk and the // whole burst is still buffered when the deadline fires. root, children := buildSharedParentFanout(t, r, 500) stats := afterDephantomize(newPollCancelCtx(2), r, root, true) if stats.commits != 0 { t.Errorf("cascade committed %d transactions after cancellation, want 0: "+ "the open batch was flushed past the deadline (issue #166's overshoot)", stats.commits) } if got := countCrosslinked(t, r, children); got != 0 { t.Errorf("crosslinked %d of %d children after the deadline fired, want 0", got, len(children)) } }