Artifact 693d67f73add68f64a76df77fe7519b9bef42857b2eec1d6709490a4af0cbf48:
- File
internal/manifest/timeline_test.go
— part of check-in
[168e20fc91]
at
2026-07-19 16:30:58
on branch trunk
— fix: make Timeline pagination cursor an exact total order (#62)
* fix: make Timeline pagination cursor an exact total order
#54 (merged as 95cbbb7) split Repo.Timeline into a repository-wide enumeration and Repo.Ancestry, the original first-parent walk under an honest name. Its pagination cursor predicate used a tolerance window around the caller-supplied Before/After pair to work around a lossy julian<->time.Time round trip:
mtime < (C - eps) OR (mtime <= (C + eps) AND rid < After)
This reintroduces exactly the defect the composite cursor existed to prevent, and makes it worse: rows within eps of the boundary that are not real ties get filtered by rid instead of tie-broken by it, which can skip a row, duplicate another, or fail to advance the cursor at all — unlike canonical fossil's own bare-timestamp slop, which repeats rows but still advances. A pagination test using only exact-tie and one-hour-apart fixtures could not exercise the 0 < delta < eps range where the defect lives, so it stayed green.
Fix: make the predicate an exact total order, with rid as a true tie-break only at exact mtime equality:
mtime < C OR (mtime = C AND rid < After)
and stop deriving C from a time.Time altogether. TimelineOpts.After is now an opaque Cursor (internal/fsltype.Cursor, re-exported as libfossil.Cursor): obtained only from a LogEntry the library already produced, carrying the exact float64 mtime read off that row rather than one reconstructed from a rounded timestamp. A cursor built from parts can silently drift from the row it claims to follow; a cursor that can only ever be handed back cannot.
That exactness depends on the row's scanned mtime being faithful to begin with, which surfaced a second bug: under the ncruces/WASM sqlite driver, event.mtime scans as a time.Time via the driver's own internal REAL conversion, carrying ~13us of noise that db.ScanJulianDay's time.Time branch then truncated (via UnixMilli()) into a full missed millisecond — independent of and in addition to the JulianToTime truncation bug #54 already fixed on the read-back side. Fixed db.timeToJulian to round to the nearest millisecond, the same class of fix, at the point where the driver-specific noise actually enters.
Also tightened the regression coverage: - Pagination tests now use a fixed loop bound of exactly ceil(n/pageSize) pages rather than an open "while non-empty" loop, so a non-advancing cursor fails the test immediately instead of hanging it. - Added a sub-millisecond-boundary fixture (events 3ms apart, well inside the old epsilon window) with rid order deliberately decoupled from mtime order — a monotonic fixture cannot expose this class of bug, since rid and mtime need not move together in general (backdated commits, imports). - TestJulianRoundTrip now asserts exact equality for values originating from TimeToJulian instead of a *1ms tolerance, which would have passed under both the old truncating JulianToTime and the current rounding one and so was not a regression guard for that fix.
No change to Ancestry, event-type filtering, NULL tolerance, or either loop bound; all were already correct.
* fix: assert pagination termination; mark Cursor change breaking
Both pagination tests (the pre-existing ones and the sub-millisecond regression) ran exactly ceil(n/pageSize) pages and asserted each event appeared once, but neither ever called Timeline one more time to confirm the next page comes back empty. The headline symptom of the epsilon-cursor defect these tests exist to catch was non-termination — a caller paginating until empty never stops — and that specific failure mode was going unguarded: a cursor that enumerates every row correctly but never signals exhaustion would have passed all four tests as they stood.
Added one more Timeline call after the last expected page in all four tests, asserting it returns zero entries. Verified this is not vacuous: reverted the tie-break to <= (non-strict) and confirmed the existing per-event count checks catch it; separately, made Timeline phantom-return one row only on an already-exhausted call (isolated from the real per-page counts, which stayed correct in that experiment) and confirmed only the new final-page assertion fails — proving it exercises a real, non-redundant path, not one already covered by the count checks. Both experiments reverted before commit.
Also corrected the CHANGELOG bullet describing the Cursor redesign: it described the new Cursor type but never stated that TimelineOpts.Before (time.Time) and TimelineOpts.After (FslID) were deleted outright, not deprecated. A reader diffing releases would have read it as an addition rather than a second compile-breaking change on top of #54. (user: [email protected], size: 12965) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "testing" "time" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/deck" libfossil "github.com/danmestas/libfossil/internal/fsltype" ) // TestTimelineEnumeratesAllEvents is the core regression for the bug // Timeline replaces Ancestry-as-Timeline for: enumeration must see every // row of the event table, not just first-parent ancestors of one start rid. func TestTimelineEnumeratesAllEvents(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2")}}, Comment: "second", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) // A sibling branch head off the same parent — not a first-parent // ancestor of the "second" trunk tip above. Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v3")}}, Comment: "branch commit", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), Tags: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "feature-x"}, }, }) var wantCount int if err := r.DB().QueryRow("SELECT count(*) FROM event").Scan(&wantCount); err != nil { t.Fatalf("count query: %v", err) } entries, err := Timeline(r, TimelineOpts{}) if err != nil { t.Fatalf("Timeline: %v", err) } if len(entries) != wantCount { t.Fatalf("Timeline returned %d entries, want %d (SELECT count(*) FROM event)", len(entries), wantCount) } } // TestTimelineIncludesNonTrunkBranches is the acceptance criterion that // distinguishes Timeline from the old ancestry-only behavior directly: // a sibling branch head must appear even though it is not reachable by // following primary parents from any single start rid. func TestTimelineIncludesNonTrunkBranches(t *testing.T) { r := setupTestRepo(t) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "first", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2")}}, Comment: "trunk tip", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), }) _, branchUUID, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v3")}}, Comment: "branch commit", User: "testuser", Parent: rid1, Time: time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC), Tags: []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "feature-x"}, }, }) entries, err := Timeline(r, TimelineOpts{}) if err != nil { t.Fatalf("Timeline: %v", err) } found := false for _, e := range entries { if e.UUID == branchUUID { found = true } } if !found { t.Fatalf("branch commit %s not present in Timeline output: %+v", branchUUID, entries) } } // TestTimelineFiltersByType covers the canonical default (all kinds) and // an explicit ci filter, per src/timeline.c:timeline_cmd()'s // `zType && zType[0]!='a'` guard. func TestTimelineFiltersByType(t *testing.T) { r := setupTestRepo(t) Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "checkin", User: "testuser", Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), }) wikiTime := time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC) d := &deck.Deck{ Type: deck.Wiki, L: "TestPage", U: "testuser", W: []byte("wiki content"), D: wikiTime, } manifestBytes, err := d.Marshal() if err != nil { t.Fatalf("Marshal: %v", err) } if _, _, err := blob.Store(r.DB(), manifestBytes); err != nil { t.Fatalf("store wiki manifest: %v", err) } if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } var wantCi, wantAll int r.DB().QueryRow("SELECT count(*) FROM event WHERE type='ci'").Scan(&wantCi) r.DB().QueryRow("SELECT count(*) FROM event").Scan(&wantAll) if wantAll == wantCi { t.Fatalf("test fixture did not produce a mixed-type event table (all=%d ci=%d)", wantAll, wantCi) } all, err := Timeline(r, TimelineOpts{}) if err != nil { t.Fatalf("Timeline: %v", err) } if len(all) != wantAll { t.Fatalf("Timeline() len = %d, want %d", len(all), wantAll) } ciOnly, err := Timeline(r, TimelineOpts{Type: libfossil.EventKindCheckin}) if err != nil { t.Fatalf("Timeline(Type=ci): %v", err) } if len(ciOnly) != wantCi { t.Fatalf("Timeline(Type=ci) len = %d, want %d", len(ciOnly), wantCi) } for _, e := range ciOnly { if e.Kind != libfossil.EventKindCheckin { t.Fatalf("Timeline(Type=ci) returned kind %q", e.Kind) } } } // TestTimelineOrdering asserts (mtime DESC, rid DESC) — the deliberate // divergence from canonical fossil's bare `ORDER BY event.mtime DESC`. // Two entries share an identical mtime; the higher rid must sort first. func TestTimelineOrdering(t *testing.T) { r := setupTestRepo(t) sameTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) rid1, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v1")}}, Comment: "one", User: "testuser", Time: sameTime, }) rid2, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte("v2")}}, Comment: "two", User: "testuser", Parent: rid1, Time: sameTime, }) entries, err := Timeline(r, TimelineOpts{}) if err != nil { t.Fatalf("Timeline: %v", err) } if len(entries) != 2 { t.Fatalf("len = %d, want 2", len(entries)) } if entries[0].RID != rid2 || entries[1].RID != rid1 { t.Fatalf("order = [%d, %d], want [%d, %d] (rid DESC tie-break on equal mtime)", entries[0].RID, entries[1].RID, rid2, rid1) } } // TestTimelinePagination is the acceptance criterion that paginating the // full set in pages of N yields each event exactly once — no duplicates // at boundaries, no gaps — even when several events share an identical // mtime, which is exactly where canonical fossil's bare-timestamp cursor // can repeat or skip rows. func TestTimelinePagination(t *testing.T) { r := setupTestRepo(t) sameTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) otherTime := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC) var allUUIDs []string var parent libfossil.FslID for i := 0; i < 5; i++ { ts := sameTime if i >= 3 { ts = otherTime // last two entries get a distinct, earlier mtime } rid, uuid, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte(fmt.Sprintf("v%d", i))}}, Comment: fmt.Sprintf("commit %d", i), User: "testuser", Parent: parent, Time: ts, }) if err != nil { t.Fatalf("Checkin %d: %v", i, err) } parent = rid allUUIDs = append(allUUIDs, uuid) } const pageSize = 2 // Exactly ceil(n/pageSize) pages: a fixed bound, not a "while non-empty" // loop, so a cursor that fails to advance fails this test immediately // instead of hanging it. maxPages := (len(allUUIDs) + pageSize - 1) / pageSize seen := make(map[string]int) var order []string var after libfossil.Cursor for page := 0; page < maxPages; page++ { entries, err := Timeline(r, TimelineOpts{Limit: pageSize, After: after}) if err != nil { t.Fatalf("Timeline page %d: %v", page, err) } if len(entries) == 0 { t.Fatalf("page %d returned 0 entries after only %d of %d events seen — cursor did not advance", page, len(order), len(allUUIDs)) } for _, e := range entries { seen[e.UUID]++ order = append(order, e.UUID) } after = entries[len(entries)-1].Cursor } if len(order) != len(allUUIDs) { t.Fatalf("paginated total = %d entries across exactly %d pages, want %d: %v", len(order), maxPages, len(allUUIDs), order) } for _, uuid := range allUUIDs { if seen[uuid] != 1 { t.Fatalf("event %s seen %d times across %d pages, want exactly 1", uuid, seen[uuid], maxPages) } } // The defining symptom of a broken cursor is non-termination: every row // enumerated correctly, but the cursor never signals exhaustion. One // more call past the last real page must come back empty. final, err := Timeline(r, TimelineOpts{Limit: pageSize, After: after}) if err != nil { t.Fatalf("final page: %v", err) } if len(final) != 0 { t.Fatalf("pagination did not terminate: page after the last expected page returned %d entries, want 0: %v", len(final), final) } } // TestTimelinePaginationSubMillisecondBoundary is the regression test for // a cursor predicate that only distinguishes rows several milliseconds // apart via a tolerance window: rid stops being a true tie-break at exact // mtime equality and becomes a filter applied to rows that are not ties, // which can skip a row entirely, duplicate another, or fail to advance // the cursor at all. rid assignment order (checkin/insertion order) is // deliberately decoupled from mtime order here — the two need not agree // in general (backdated commits, imports) — because a predicate bug that // only manifests when they disagree would pass unnoticed against a // fixture where they happen to move together. func TestTimelinePaginationSubMillisecondBoundary(t *testing.T) { r := setupTestRepo(t) const n = 6 const pageSize = 2 base := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) var parent libfossil.FslID var allUUIDs []string for i := 0; i < n; i++ { ts := base.Add(time.Duration(n-1-i) * 3 * time.Millisecond) // 3ms apart, descending as rid ascends rid, uuid, err := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte(fmt.Sprintf("v%d", i))}}, Comment: fmt.Sprintf("commit %d", i), User: "testuser", Parent: parent, Time: ts, }) if err != nil { t.Fatalf("Checkin %d: %v", i, err) } parent = rid allUUIDs = append(allUUIDs, uuid) } maxPages := (n + pageSize - 1) / pageSize // exactly ceil(n/pageSize) = 3 seen := make(map[string]int) var order []string var after libfossil.Cursor for page := 0; page < maxPages; page++ { entries, err := Timeline(r, TimelineOpts{Limit: pageSize, After: after}) if err != nil { t.Fatalf("page %d: %v", page, err) } if len(entries) == 0 { t.Fatalf("page %d returned 0 entries after only %d of %d events seen (order so far: %v) — cursor did not advance", page, len(order), n, order) } for _, e := range entries { seen[e.UUID]++ order = append(order, e.UUID) } after = entries[len(entries)-1].Cursor } if len(order) != n { t.Fatalf("paginated total = %d entries across exactly %d pages, want %d: order=%v", len(order), maxPages, n, order) } for _, uuid := range allUUIDs { if seen[uuid] != 1 { t.Fatalf("event %s seen %d times across %d pages (bound=ceil(%d/%d)), want exactly 1", uuid, seen[uuid], maxPages, n, pageSize) } } // Non-termination is the actual user-facing failure mode: every row // enumerated correctly but the cursor never signals exhaustion. Assert // the page past the last expected one comes back empty. final, err := Timeline(r, TimelineOpts{Limit: pageSize, After: after}) if err != nil { t.Fatalf("final page: %v", err) } if len(final) != 0 { t.Fatalf("pagination did not terminate: page after the last expected page returned %d entries, want 0: %v", len(final), final) } } // TestTimelineLimit covers that Limit both bounds the result and is // reachable — the original bug's defining symptom was a limit that never // bound because the (wrong) walk exhausted first. func TestTimelineLimit(t *testing.T) { r := setupTestRepo(t) var parent libfossil.FslID for i := 0; i < 5; i++ { rid, _, _ := Checkin(r, CheckinOpts{ Files: []File{{Name: "a.txt", Content: []byte(fmt.Sprintf("v%d", i))}}, Comment: fmt.Sprintf("commit %d", i), User: "testuser", Parent: parent, Time: time.Date(2024, 1, 15, 10, i, 0, 0, time.UTC), }) parent = rid } entries, err := Timeline(r, TimelineOpts{Limit: 2}) if err != nil { t.Fatalf("Timeline: %v", err) } if len(entries) != 2 { t.Fatalf("len = %d, want 2 (Limit must bind and be reachable)", len(entries)) } } // TestTimelineInvalidType covers the TigerStyle "fail fast and loudly on // programmer error" requirement: an EventKind that is not one of the six // recognized codes (or the zero value) is a caller bug, not unusual-but- // valid data. func TestTimelineInvalidType(t *testing.T) { r := setupTestRepo(t) defer func() { if recover() == nil { t.Fatal("Timeline did not panic on an invalid EventKind") } }() Timeline(r, TimelineOpts{Type: libfossil.EventKind("bogus")}) } // TestTimelineNilRepoPanics matches the codebase's `panic("pkg.Func: arg // must not be nil")` idiom for programmer error (see internal/blob/blob.go). func TestTimelineNilRepoPanics(t *testing.T) { defer func() { if recover() == nil { t.Fatal("Timeline did not panic on a nil repo") } }() Timeline(nil, TimelineOpts{}) }