Artifact 26ce827d6d409da5ae6360b78fb149105b6aef7ac5ce9ba194f10fe179fee566:
- File
hub/repo.go
— part of check-in
[00bc87892b]
at
2026-05-12 23:50:36
on branch trunk
— feat(hub): persist .commit publish watermark across hub restarts (#164)
publishedRID was in-memory only. initPublishWatermark snapshotted the current max ci rid on every start, so any commit that landed in the repo but didn't get its .commit notification out before the hub died (crash mid-publish, HTTP push that completed the SQLite write but crashed before the wrapper ran publishNewCommits, or an offline tool writing to the repo while the hub was down) became silently un-propagated. Peers caught up incidentally on the next commit or not at all. Real gap.
Persist the watermark to the repo's config table under "edgesync:hub-published-rid" after each successful publish. On startup, initPublishWatermark reads the persisted value first and falls back to the max-rid snapshot only when nothing is persisted (cold start). Schedule a one-shot publishNewCommits call publishCatchupDelay (3s) after NewHub so it runs after NATS leaf clients have had time to reconnect on the same leaf port — an immediate catchup fires into a void because nats-core has no per-subscriber queuing.
Stop cancels the pending catchup via catchupStop so the goroutine doesn't outlive the hub.
Regression test TestCrossLeaf_HubRestart_RepublishesUnannouncedCommits: two hubs leaf-linked on a stable leaf port; stop A, open the repo file directly via libfossil and commit (no hub, no NATS, no publish hook), restart A; assert B reads the offline file via the delayed startup catchup. The "open repo file directly" trick is morally equivalent to "hub crashed after libfossil's write but before the wrapper's publish" — both produce a commit that's in the repo but unannounced.
Known limitations (deferred): - If no peer reconnects within publishCatchupDelay, the catchup publish is still lost. JetStream-durable .commit subject would close the gap; tracked as a follow-up. - The .commit subscriber on the pull side doesn't advance the receiving hub's watermark, so a pulled-in commit will be republished on the receiver's next restart. Idempotent on the far side (the subscriber's pull converges to zero blobs) but wasteful chatter; can be tightened in a follow-up. - v1→v2 upgrade with un-announced backlog at the time of upgrade treats those commits as "already known" — same silent-loss behavior as v1; not made worse.
Refs #161 review notes.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (user: [email protected], size: 12126) [annotate] [blame] [check-ins using]
package hub import ( "context" "database/sql" "errors" "fmt" "log" "strconv" "sync" libfossil "github.com/danmestas/libfossil" ) // publishedRIDConfigKey is the libfossil-config row that persists the // .commit publish watermark across hub restarts. Stored in the repo's // own config table (rather than a sidecar file) so the watermark moves // with the repo and operators can inspect it via fossil tooling. The // "edgesync:" prefix namespaces against fossil's own config keys. const publishedRIDConfigKey = "edgesync:hub-published-rid" // Repo is a hub fossil repo opened for direct operations — user mgmt, // commits, reads. No embedded NATS server, no HTTP listener; it's the // right handle for one-off CLI invocations like `<binary> hub user list` // where spinning up the full hub daemon would be wasteful. // // For the daemon use case (running hub serving leaves), construct a // *Hub via NewHub. A *Hub holds a *Repo internally; access it via // Hub.Repo if you need to share the handle with another component // in the same process. Don't call Close on a *Repo returned by // Hub.Repo while the Hub is still serving — Hub.Stop is the right // teardown for that case. type Repo struct { handle *libfossil.Repo closeMu sync.Mutex closed bool // publish, when non-nil, is invoked after a successful Commit with the // new commit's rid and uuid. NewHub wires this to a NATS publisher on // "<prefix>.<project-code>.commit" so peer hubs can pull the new // commit. Standalone OpenRepo callers leave it nil (no publish). // // Implementations MUST be non-blocking — Commit calls publish on the // foreground path and a slow publisher would back-pressure writers. // Best-effort by contract: publish failures must not fail the commit. publish func(rid int64, uuid string) // publishMu serializes publishNewCommits so concurrent callers (the // local Commit path and the HTTP xfer-push wrapper) cannot interleave // scans of the event table and race the publishedRID watermark. publishMu sync.Mutex // publishedRID is the highest 'ci' event rid that has been emitted on // the .commit subject. publishNewCommits only emits for rids strictly // greater than this, then advances it. Persisted to the repo's config // table under publishedRIDConfigKey after each advance and reloaded // by initPublishWatermark on hub startup so commits that landed via // HTTP push but never got their .commit publish out before a crash // can be republished by the startup catchup in startCommitSubscriber. publishedRID int64 } // OpenRepo opens an existing hub repo at path. Returns an error if the // path doesn't exist or isn't a valid fossil repo. The caller owns the // returned *Repo and must Close it when done. func OpenRepo(path string) (*Repo, error) { if path == "" { return nil, errors.New("hub: OpenRepo: path is required") } h, err := libfossil.Open(path) if err != nil { return nil, fmt.Errorf("hub: open repo at %s: %w", path, err) } applySQLiteTuning(h) return &Repo{handle: h}, nil } // newRepoFromHandle wraps an already-opened libfossil handle as a *Repo. // Used internally by NewHub so the Hub and direct Repo callers share // method bodies. func newRepoFromHandle(h *libfossil.Repo) *Repo { return &Repo{handle: h} } // Close releases the underlying libfossil handle. Idempotent — second // and subsequent calls return nil. func (r *Repo) Close() error { r.closeMu.Lock() defer r.closeMu.Unlock() if r.closed { return nil } r.closed = true if err := r.handle.Close(); err != nil { return fmt.Errorf("hub: close repo: %w", err) } return nil } // AddUser creates a fossil user. Errors if Login is empty or the user // already exists. func (r *Repo) AddUser(u User) error { if u.Login == "" { return errors.New("hub: AddUser: Login is required") } if err := r.handle.CreateUser(libfossil.UserOpts{Login: u.Login, Caps: u.Caps}); err != nil { return fmt.Errorf("hub: add user %q: %w", u.Login, err) } return nil } // GetUser returns the user with the given login. Errors if no such user // exists. func (r *Repo) GetUser(login string) (User, error) { u, err := r.handle.GetUser(login) if err != nil { return User{}, fmt.Errorf("hub: get user %q: %w", login, err) } return User{Login: u.Login, Caps: u.Caps}, nil } // HasUser reports whether a user with the given login exists. func (r *Repo) HasUser(login string) bool { _, err := r.handle.GetUser(login) return err == nil } // ListUsers returns all users on the hub repo. func (r *Repo) ListUsers() ([]User, error) { users, err := r.handle.ListUsers() if err != nil { return nil, fmt.Errorf("hub: list users: %w", err) } out := make([]User, len(users)) for i, u := range users { out[i] = User{Login: u.Login, Caps: u.Caps} } return out, nil } // RemoveUser deletes the user with the given login. func (r *Repo) RemoveUser(login string) error { if err := r.handle.DeleteUser(login); err != nil { return fmt.Errorf("hub: remove user %q: %w", login, err) } return nil } // SetUserCaps replaces the caps string on an existing user. Errors if the // user doesn't exist or the underlying SetCaps fails. func (r *Repo) SetUserCaps(login, caps string) error { if login == "" { return errors.New("hub: SetUserCaps: login is required") } if err := r.handle.SetCaps(login, caps); err != nil { return fmt.Errorf("hub: set caps for %q: %w", login, err) } return nil } // Commit commits a set of files to the hub repo with the given message // and author. Returns the new manifest UUID as an opaque RevID. func (r *Repo) Commit(ctx context.Context, opts CommitOpts) (RevID, error) { if opts.Author == "" { return "", errors.New("hub: Commit: Author is required") } files := make([]libfossil.FileToCommit, len(opts.Files)) for i, f := range opts.Files { files[i] = libfossil.FileToCommit{Name: f.Name, Content: f.Content, Perm: f.Perm} } var tags []libfossil.TagSpec if len(opts.Tags) > 0 { tags = make([]libfossil.TagSpec, len(opts.Tags)) for i, t := range opts.Tags { tags[i] = libfossil.TagSpec{Name: t.Name, Value: t.Value} } } _, uuid, err := r.handle.Commit(libfossil.CommitOpts{ Files: files, Comment: opts.Message, User: opts.Author, Time: opts.Time, Tags: tags, }) if err != nil { return "", fmt.Errorf("hub: commit: %w", err) } r.publishNewCommits() return RevID(uuid), nil } // publishNewCommits invokes r.publish for every 'ci' event whose rid is // strictly greater than r.publishedRID, in rid order, then advances the // watermark. No-op when r.publish is nil (standalone OpenRepo, or hub // constructed with Config.DisableFossilSyncOverNATS=true). // // Both code paths that can land a new commit funnel through here: // // - Repo.Commit (local, in-process commits): calls this after libfossil // has written the new event; the watermark scan picks up exactly the // one rid that just landed. // - The HTTP xfer-push wrapper installed by Hub.ServeHTTP: calls this // after every xfer request so commits arriving via `fossil push // <hub-http>` from an external CLI agent emit the same .commit // notification a Repo.Commit caller would have. Without that wrapper, // HTTP-pushed commits land in the repo but peers never get notified // (issue #160). A single multi-commit push emits one notification per // new ci event so each lands on the subject in rid order. // // publishMu serialises the scan/publish/advance triple so concurrent // callers cannot republish the same rid. Publish errors are logged inside // the publish hook (see startCommitSubscriber) and never bubble up — both // call sites treat publish as best-effort. func (r *Repo) publishNewCommits() { r.publishMu.Lock() defer r.publishMu.Unlock() if r.publish == nil { return } rows, err := r.handle.DB().Query( "SELECT objid FROM event WHERE type='ci' AND objid > ? ORDER BY objid", r.publishedRID, ) if err != nil { log.Printf("hub publish: enumerate new commits since rid=%d: %v", r.publishedRID, err) return } var newRIDs []int64 for rows.Next() { var rid int64 if scanErr := rows.Scan(&rid); scanErr != nil { _ = rows.Close() log.Printf("hub publish: scan rid: %v", scanErr) return } newRIDs = append(newRIDs, rid) } _ = rows.Close() if err := rows.Err(); err != nil { log.Printf("hub publish: enumerate new commits since rid=%d: rows error: %v", r.publishedRID, err) return } for _, rid := range newRIDs { uuid, err := r.handle.UUIDFromRID(rid) if err != nil { log.Printf("hub publish: uuid for rid=%d: %v", rid, err) continue } r.publish(rid, uuid) r.publishedRID = rid if setErr := r.handle.SetConfig(publishedRIDConfigKey, strconv.FormatInt(rid, 10)); setErr != nil { // Best-effort: a failed persist means a future restart may // republish this rid. Idempotent on the subscriber side // (the .commit subscriber's pull converges to zero blobs // when nothing new), so harmless beyond a little extra // chatter. Log so the drift is visible. log.Printf("hub publish: persist watermark rid=%d: %v", rid, setErr) } } } // initPublishWatermark restores publishedRID from the previous hub run. // NewHub calls this in startCommitSubscriber immediately after wiring // r.publish, before the startup catchup. Two paths: // // - Warm start: a persisted value exists under publishedRIDConfigKey. // Load it. The startup catchup will then republish any commits that // landed while the hub was down (e.g. an HTTP push that crashed the // hub mid-publish, or commits made by an offline tool against the // same repo file). // // - Cold start: no persisted value (first run on this repo, or a v1 // upgrade). Snapshot the current max ci rid and persist it. Commits // already in the repo (notably anything pulled in by // SeedFromUpstream) are treated as "already known to peers" — the // same semantic as before persistence was added. Commits made while // a previous v1 hub was alive but un-announced before crash do NOT // get republished on the v1→v2 transition; tracked in the PR as a // known limitation and recoverable by a subsequent commit (whose // publish notification triggers a pull that idempotently catches the // backlog up). // // Best-effort across the board: failure to read or persist leaves // publishedRID at its zero value, which the subsequent publishNewCommits // would treat as "republish everything" — harmless because the .commit // subscriber's pull is idempotent, but logged so the drift is visible. func (r *Repo) initPublishWatermark() { r.publishMu.Lock() defer r.publishMu.Unlock() val, err := r.handle.Config(publishedRIDConfigKey) if err == nil { parsed, parseErr := strconv.ParseInt(val, 10, 64) if parseErr == nil { r.publishedRID = parsed return } log.Printf("hub publish: parse persisted watermark %q: %v; falling back to max-rid snapshot", val, parseErr) } else if !errors.Is(err, sql.ErrNoRows) { log.Printf("hub publish: read persisted watermark: %v; falling back to max-rid snapshot", err) } var maxRID int64 if scanErr := r.handle.DB().QueryRow( "SELECT COALESCE(MAX(objid), 0) FROM event WHERE type='ci'", ).Scan(&maxRID); scanErr != nil { log.Printf("hub publish: init watermark: %v", scanErr) return } r.publishedRID = maxRID if setErr := r.handle.SetConfig(publishedRIDConfigKey, strconv.FormatInt(maxRID, 10)); setErr != nil { log.Printf("hub publish: persist initial watermark %d: %v", maxRID, setErr) } } // Read returns the contents of path at the current trunk tip. func (r *Repo) Read(ctx context.Context, path string) ([]byte, error) { return r.handle.ReadFileAt("trunk", path) } // ReadAt returns the contents of path at rev. rev may be a branch name // (e.g. "trunk"), a tag, or a RevID returned from Commit. func (r *Repo) ReadAt(ctx context.Context, rev RevID, path string) ([]byte, error) { if rev == "" { return nil, errors.New("hub: ReadAt: rev is required (use Read for current tip)") } return r.handle.ReadFileAt(string(rev), path) }