Artifact 02517f89784e23b3f2d1d8e4a88563ea955f0e450c23fa9bdbbf15d0a6a9b8f4:
- File
internal/manifest/timeline.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: 5117) [annotate] [blame] [check-ins using]
package manifest import ( "database/sql" "fmt" "strings" "github.com/danmestas/libfossil/db" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/repo" ) // TimelineOpts configures a repository-wide event enumeration. type TimelineOpts struct { // Type restricts the enumeration to a single event.type code. The zero // value means "all kinds" — the canonical `fossil timeline` default. Type libfossil.EventKind // After, when valid, restricts results to events strictly after this // cursor in Timeline's (mtime DESC, rid DESC) order — i.e. the next // page following whatever LogEntry produced it. The zero Cursor means // "start from the newest event". Obtain a Cursor from a LogEntry's // Cursor field; do not construct one by hand (see fsltype.Cursor). After libfossil.Cursor // Limit caps the number of rows returned. Zero or negative means // unbounded (matches LogOpts.Limit's convention). Limit int } // maxTimelineRows bounds the enumeration's row loop so that an unbounded // query (Limit <= 0) against a corrupt or adversarial event table cannot // hang the process (TigerStyle: every loop needs an explicit bound). A var, // not a const, so tests can shrink it. No real fossil repository has // anywhere near this many events. var maxTimelineRows = 10_000_000 // Timeline enumerates the event table newest-first: every event by // default, or just the events matching opts.Type when set. This is the // repository-wide enumeration fossil's own `timeline` command performs — // it does not follow plink at all, so it is not limited to first-parent // ancestors of any single check-in the way Log (the ancestry walk) is. // // Ordering is (mtime DESC, rid DESC), a total order with rid as a true // tie-break at exact mtime equality — a deliberate improvement over // canonical fossil, which orders by mtime DESC alone with no tie-break and // pages using a bare timestamp cursor with a one-second slop, so rows at a // page boundary can repeat or be skipped there. Pass the last returned // entry's Cursor back as opts.After to fetch the next page without that // hazard. The predicate depends on the cursor's mtime component being the // exact float64 read off the row — see fsltype.Cursor's doc comment for // why that value must never be reconstructed from a time.Time. func Timeline(r *repo.Repo, opts TimelineOpts) ([]LogEntry, error) { if r == nil { panic("manifest.Timeline: r must not be nil") } if !opts.Type.Valid() { panic("manifest.Timeline: opts.Type must be a valid EventKind") } query := "SELECT b.uuid, e.user, e.comment, e.mtime, e.objid, e.type " + "FROM event e JOIN blob b ON b.rid = e.objid" var conds []string var args []any if opts.Type != "" { conds = append(conds, "e.type = ?") args = append(args, string(opts.Type)) } if opts.After.Valid() { conds = append(conds, "(e.mtime < ? OR (e.mtime = ? AND b.rid < ?))") args = append(args, opts.After.Julian(), opts.After.Julian(), int64(opts.After.RID())) } if len(conds) > 0 { query += " WHERE " + strings.Join(conds, " AND ") } query += " ORDER BY e.mtime DESC, b.rid DESC" if opts.Limit > 0 { query += " LIMIT ?" args = append(args, opts.Limit) } rows, err := r.DB().Query(query, args...) if err != nil { return nil, fmt.Errorf("manifest.Timeline: %w", err) } defer rows.Close() var entries []LogEntry for rows.Next() { if len(entries) >= maxTimelineRows { return nil, fmt.Errorf("manifest.Timeline: exceeded max row bound %d", maxTimelineRows) } // event.user and event.comment are nullable (see Log's comment on // the same substitution); event.type is NOT NULL for any row this // query can produce (it is set at insert time by crosslink for // every event kind). var uuid, kind string var user, comment sql.NullString var mtimeScanned any var rid int64 if err := rows.Scan(&uuid, &user, &comment, &mtimeScanned, &rid, &kind); err != nil { return nil, fmt.Errorf("manifest.Timeline: scan: %w", err) } mtime, ok := db.ScanJulianDay(mtimeScanned) if !ok { return nil, fmt.Errorf("manifest.Timeline: rid=%d: unexpected mtime type %T", rid, mtimeScanned) } entry := LogEntry{ RID: libfossil.FslID(rid), UUID: uuid, Comment: comment.String, User: user.String, Time: libfossil.JulianToTime(mtime), Kind: libfossil.EventKind(kind), // Cursor carries mtime as the exact float64 just read off this // row — not a value re-derived from the Time field above — so // handing it back as the next page's After compares equal to // this row bit-for-bit. See fsltype.Cursor's doc comment. Cursor: libfossil.NewCursor(mtime, libfossil.FslID(rid)), } // Parents come from plink, which only relates check-in artifacts — // leaving Parents empty for every other kind is correct, not a gap. if entry.Kind == libfossil.EventKindCheckin { entry.Parents = parentUUIDs(r, entry.RID) } entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("manifest.Timeline: %w", err) } return entries, nil }