Artifact b2bc7d29f6eef138d65620f637406869af0d06396c5702739768c2ad605c42a3:
- File
internal/manifest/timeline.go
— part of check-in
[29b53e75d2]
at
2026-10-26 03:24:05
on branch main
— fix: timelineSince means arrived after, not stamped after
A sync handler asks what a push brought in. Filtering on timestamps missed an artifact made on a machine whose clock runs behind the given check-in's. TimelineSince now keeps entries whose artifacts were stored after the given one: a later receipt (blob.rcvid, which a phantom fill updates), then a higher rid. Any held artifact hash is accepted; an unknown one is an error.
Tracked in #504. (user: [email protected], size: 8329) [annotate] [blame] [check-ins using] [more...]
package manifest import ( "database/sql" "fmt" "strings" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-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 TimelineEntry produced it. The zero Cursor // means "start from the newest event". Obtain a Cursor from a // TimelineEntry'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 // Kinds, when not empty, restricts the enumeration to events of any of // these kinds, alongside Type. Kinds []libfossil.EventKind // ArrivedAfter, when positive, restricts results to events whose artifact // was stored after the artifact with this rid: a later receipt (rcvid), // or the same receipt and a higher rid. See arrivedAfterCond. ArrivedAfter libfossil.FslID } // TimelineEntry is a single event as returned by Timeline. It extends // LogEntry with Cursor, the pagination token for this row: Timeline is the // operation with pagination semantics, so its own result type — not the // shared LogEntry — is where the cursor belongs. Cursor is always valid // (fsltype.Cursor.Valid() == true) on every entry Timeline produces. // // TimelineEntry values should come from Timeline, not be hand-assembled. // A TimelineEntry built by embedding a LogEntry from Log/Ancestry carries // a zero-value Cursor, which Timeline treats as "start from newest" rather // than as an error. type TimelineEntry struct { LogEntry Cursor libfossil.Cursor } // 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) ([]TimelineEntry, 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") } for _, k := range opts.Kinds { if k == "" || !k.Valid() { panic("manifest.Timeline: opts.Kinds must hold valid, non-empty EventKinds") } } query, args := timelineQuery(opts) labels, err := content.BelowLabels(r.DB()) if err != nil { return nil, fmt.Errorf("manifest.Timeline: %w", err) } rows, err := r.DB().Query(query, args...) if err != nil { return nil, fmt.Errorf("manifest.Timeline: %w", err) } defer rows.Close() var entries []TimelineEntry 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.ScanTextJulianDay(mtimeScanned) if !ok { return nil, fmt.Errorf("manifest.Timeline: rid=%d: unexpected mtime type %T", rid, mtimeScanned) } entry := TimelineEntry{ LogEntry: 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) entry.HorizonParents = horizonParents(labels, entry.Parents) } entries = append(entries, entry) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("manifest.Timeline: %w", err) } return entries, nil } // timelineQuery builds Timeline's SELECT and its arguments from opts. func timelineQuery(opts TimelineOpts) (string, []any) { 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 len(opts.Kinds) > 0 { marks := strings.TrimSuffix(strings.Repeat("?, ", len(opts.Kinds)), ", ") conds = append(conds, "e.type IN ("+marks+")") for _, k := range opts.Kinds { args = append(args, string(k)) } } 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 after := int64(opts.ArrivedAfter); after > 0 { conds = append(conds, arrivedAfterCond) args = append(args, after, after, after) } 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) } return query, args } // arrivedAfterCond orders artifacts by when this repository stored their // content. blob.rcvid names the receipt that delivered it -- one push, one // pull, one local write -- and is set again when a phantom is filled, so an // artifact first known as a phantom counts as arriving with its content; a // rid alone would not, being allocated when the phantom is made. Within one // receipt the rid breaks the tie. const arrivedAfterCond = "(coalesce(b.rcvid, 0) > " + "(SELECT coalesce(rcvid, 0) FROM blob WHERE rid = ?) " + "OR (coalesce(b.rcvid, 0) = (SELECT coalesce(rcvid, 0) FROM blob WHERE rid = ?) " + "AND b.rid > ?))" // ArtifactRID returns the rid of the artifact with full hash uuid whose // content the repository holds, or an error naming it. func ArtifactRID(q db.Querier, uuid string) (libfossil.FslID, error) { var rid int64 err := q.QueryRow("SELECT rid FROM blob WHERE uuid = ? AND size >= 0", strings.ToLower(uuid)).Scan(&rid) if err == sql.ErrNoRows { return 0, fmt.Errorf("unknown artifact %s", uuid) } if err != nil { return 0, fmt.Errorf("artifact rid: %w", err) } return libfossil.FslID(rid), nil }