Skip to content
go-libfossil Check-in [9a2697c556]
Login

Check-in [9a2697c556]

Overview
Comment:fix: split repository enumeration from ancestry walking (#45) (#54)

Repo.Timeline was an ancestry walk wearing an enumeration's name: it required a start rid, emitted that check-in, followed plink where isprim=1 to the primary parent, and repeated until the chain ended. It never touched the event table and never visited a second parent or a sibling branch head, so it silently omitted any check-in that wasn't a first-parent ancestor of the given start rid — on libfossil's own repository that was 577 of 3,761 check-ins, with no error and no warning. The limit option never bound because the (wrong) walk exhausted first.

Split into two operations under honest names:

- Repo.Timeline(TimelineOpts) enumerates the event table newest first, every kind by default or a single kind via Type. This matches canonical `fossil timeline`'s -t/--type default (a filter only applies when given and not 'a'). - Repo.Ancestry(LogOpts) keeps the original first-parent walk, unchanged in behavior, under a name that says what it does.

Breaking change, no deprecated shim (pre-1.0, clean break): callers of the old Timeline(LogOpts) need to move to Ancestry(LogOpts) if they want the walk, or to Timeline(TimelineOpts) if they want the enumeration fossil's own timeline command performs. The only in-tree callers were the `repo timeline` CLI command and the two examples; all three are migrated to Timeline(TimelineOpts). See CHANGELOG.md.

LogEntry gains a Kind field identifying which of event.type's six kinds (ci, e, f, g, t, w) an entry is; Parents is only populated for check-ins, since plink only relates check-in artifacts.

Timeline's pagination cursor orders by (mtime DESC, rid DESC) instead of canonical fossil's bare mtime DESC with no tie-break — a deliberate improvement, not an oversight, since fossil's web cursor combines that missing tie-break with a one-second timestamp slop and can repeat or skip rows at a page boundary. Getting the composite cursor to actually round-trip losslessly required fixing a real bug along the way: JulianToTime truncated instead of rounded when converting a julian day back to milliseconds, so TimeToJulian(JulianToTime(m)) was not always equal to m even for exact-millisecond inputs — enough to misplace a row at a page boundary under exactly the same-mtime conditions this issue's acceptance criteria test for. Fixed to round to nearest. Because the same class of drift also appears in the ncruces/WASM sqlite driver's own internal REAL-to-time.Time conversion (independent of this library's code), the cursor comparison also carries a small explicit tolerance rather than relying on bit-exact equality across drivers.

Both the ancestry walk's loop and the enumeration's row loop now carry an explicit, testable bound so a corrupt or cyclic plink table can't hang the process.

Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 9a2697c556a097fdd58075ba3e6f6f8277d8247ba78860203a676a8b2945a65c
User & Date: [email protected] on 2026-07-19 16:00:36.000
Other Links: manifest | tags
Context
2026-07-19
16:30
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. check-in: 168e20fc91 user: [email protected] tags: trunk

16:00
fix: split repository enumeration from ancestry walking (#45) (#54)

Repo.Timeline was an ancestry walk wearing an enumeration's name: it required a start rid, emitted that check-in, followed plink where isprim=1 to the primary parent, and repeated until the chain ended. It never touched the event table and never visited a second parent or a sibling branch head, so it silently omitted any check-in that wasn't a first-parent ancestor of the given start rid — on libfossil's own repository that was 577 of 3,761 check-ins, with no error and no warning. The limit option never bound because the (wrong) walk exhausted first.

Split into two operations under honest names:

- Repo.Timeline(TimelineOpts) enumerates the event table newest first, every kind by default or a single kind via Type. This matches canonical `fossil timeline`'s -t/--type default (a filter only applies when given and not 'a'). - Repo.Ancestry(LogOpts) keeps the original first-parent walk, unchanged in behavior, under a name that says what it does.

Breaking change, no deprecated shim (pre-1.0, clean break): callers of the old Timeline(LogOpts) need to move to Ancestry(LogOpts) if they want the walk, or to Timeline(TimelineOpts) if they want the enumeration fossil's own timeline command performs. The only in-tree callers were the `repo timeline` CLI command and the two examples; all three are migrated to Timeline(TimelineOpts). See CHANGELOG.md.

LogEntry gains a Kind field identifying which of event.type's six kinds (ci, e, f, g, t, w) an entry is; Parents is only populated for check-ins, since plink only relates check-in artifacts.

Timeline's pagination cursor orders by (mtime DESC, rid DESC) instead of canonical fossil's bare mtime DESC with no tie-break — a deliberate improvement, not an oversight, since fossil's web cursor combines that missing tie-break with a one-second timestamp slop and can repeat or skip rows at a page boundary. Getting the composite cursor to actually round-trip losslessly required fixing a real bug along the way: JulianToTime truncated instead of rounded when converting a julian day back to milliseconds, so TimeToJulian(JulianToTime(m)) was not always equal to m even for exact-millisecond inputs — enough to misplace a row at a page boundary under exactly the same-mtime conditions this issue's acceptance criteria test for. Fixed to round to nearest. Because the same class of drift also appears in the ncruces/WASM sqlite driver's own internal REAL-to-time.Time conversion (independent of this library's code), the cursor comparison also carries a small explicit tolerance rather than relying on bit-exact equality across drivers.

Both the ancestry walk's loop and the enumeration's row loop now carry an explicit, testable bound so a corrupt or cyclic plink table can't hang the process. check-in: 9a2697c556 user: [email protected] tags: trunk

16:00
fix: separate blob existence from blob availability (#43) (#52)

* fix: separate blob existence from blob availability (#43)

A phantom is a real blob row with size = -1 and NULL content, so blob.Exists — "SELECT rid FROM blob WHERE uuid=?" — answers true for it. Every guard built on Exists therefore admitted artifacts whose content cannot be read, and content.Expand aborted with "rid N is a phantom".

Canonical Fossil keeps two predicates and never substitutes one for the other: rid_from_uuid (existence, xfer.c:70) and content_is_available (availability, content.c:163). The latter is transitive — it follows delta.srcid up the chain, so a delta whose base is a phantom is unavailable even though every row in the chain exists.

Add content.IsAvailable / content.AvailableByUUID, ported from content_is_available including the chain walk, with an explicit depth bound so a cyclic delta table terminates instead of hanging. The walk lives in exactly one place; no caller touches the delta table.

blob.Exists keeps its behavior and gains a doc comment stating that it answers existence only, returns true for phantoms, and naming the availability predicate as the alternative.

manifest.blobPresent is deleted. It was a local patch at one call site that tested size >= 0 without the chain walk, so it still reported a delta-on-phantom as present — the same class of bug it was written to work around.

Every blob.Exists call site was classified as existence or availability and converted where it guards a content read. Sites that want rid identity — plink/mlink/tagxref population, cluster phantom creation, private-status bookkeeping, the post-store sanity check in handleFile, and phantom filling in storeReceivedFile — keep existence, since delta-before-base is a normal steady state during clone.

Verified against a loopback fossil 2.28 server: the 23-artifact repro that previously failed roughly half the time now clones 6 of 6.

* fix: IsAvailable must fail closed when the delta lookup errors

Only sql.ErrNoRows means the chain is grounded. Any other failure — cancelled context, closed connection, NULL srcid — says nothing about groundedness, so returning true there claims readable content that was never verified. That is the same class of bug this predicate exists to prevent, and it runs on data arriving over the wire during clone.

The blob-size lookup above already fails closed on the same pattern; this restores the symmetry. check-in: 670ca0e75f user: [email protected] tags: trunk

Changes