Skip to content

fullhistory: hardening + contract notes from the pre-existing-code review (closes #840)#847

Merged
chowbao merged 5 commits into
feature/full-historyfrom
chore/fullhistory-hardening-840
Jul 13, 2026
Merged

fullhistory: hardening + contract notes from the pre-existing-code review (closes #840)#847
chowbao merged 5 commits into
feature/full-historyfrom
chore/fullhistory-hardening-840

Conversation

@chowbao

@chowbao chowbao commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Closes #840. Hardening from the pre-existing-code review: cheap load-time guards, corrected doc claims, and contracts written down before #772 builds on them. No schema or on-disk format changes.

Storage roots

  • Per-root flocks dropped (review round): single-process enforcement is catalog.Open's own exclusive RocksDB LOCK — a second daemon on the same catalog fails to start. Cold artifact trees are write-once behind the one-write protocol (a shared tree is redundant work, not corruption) and each hot chunk DB carries its own RocksDB LOCK. LockRoots' load-bearing other half survives as PrepareRoots: create missing roots and fsync the new directory chain, so a fresh-deploy crash cannot lose a tree the synced catalog already references. RootsToLockRoots.
  • Paths.ValidateRoots(): distinct roots only — two roots at the same path would write into each other's trees (resolved via filepath.Abs, so unclean-spelling duplicates are caught). Nesting is deliberately allowed: prune and discard delete per-chunk paths, never a whole root.

Snapshot-distribution guards (cold trees copied between machines)

One load-time cross-check each, mirroring the chunk-ID binding events.pack already had:

  • loadMeta cross-checks offsets.TotalEvents() against the trailer item count — a mispaired offsets blob otherwise silently truncates tail events off every per-ledger range.
  • index.hash's MPHF key count must equal index.pack's record count (halves of one build), and a non-empty index on an eventless events.pack is rejected (converse of the empty-index check). The pair carries no chunk ID of its own, so these are the strongest bindings available without a format change.
  • Both eventstore packs verify Trailer().Format at open — cold_format.go claimed readers dispatch on it, but only the ledger store checked; a mis-pointed pack now fails at open instead of mid-query with an opaque zstd error.
  • The MPHF handle is separate from its verdict (review round): waitMPHF loads, validateMPHF is an error-only gate the lookup path runs — Close always gets the real handle to release, and an eventless chunk's teardown (the common case across early history) does no events.pack metadata I/O.

Contracts and point fixes

  • Query's MaxEvents-before-postFilter contract documented at QueryOptions and the cap site: a short page does not mean exhaustion, so the Cut over to RPC v2: remove SQLite ingestion/query, serve from v2 stores #772 getEvents wiring must not use len < limit ⇒ done. Documented rather than a top-up loop — the consumer doesn't exist yet.
  • InsertEvents: named error return so the deferred close-error join reaches the caller (previously assigned a dead local).
  • The warmup tamper tripwire no longer wraps at eventID == MaxUint32: the index upper bound is uint64, so the exact adversarial input the check exists for can't wrap max+1 to 0 and slip past.
  • TermsForBytes now hard-fails a non-V0 ContractEvent body, matching the SQLite backend, instead of silently indexing contractID-only (topic queries would miss real events with no signal). Flagging for review — the old behavior was defensible as forward-compat; this picks the loud option before a protocol bump picks for us.
  • FetchRange's doc matches both implementations: count == 0 is a no-op regardless of start.

Tests: ValidateRoots + PrepareRoots tables, the five cold-reader guard rejections, the MaxUint32 tripwire row, the body-version hard-fail (raw-XDR discriminant patch — the SDK can't marshal a body the protocol doesn't have yet), and the daemon contention test now pins the catalog's RocksDB-LOCK failure.

Base automatically changed from chore/fullhistory-dead-surface-841 to feature/full-history July 13, 2026 15:30
@chowbao chowbao force-pushed the chore/fullhistory-hardening-840 branch from 13cc252 to f1873e1 Compare July 13, 2026 16:49
@chowbao

chowbao commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

TODO: update when #858 is merged in

Edit: Done

@chowbao chowbao force-pushed the chore/fullhistory-hardening-840 branch from f1873e1 to 8a10b76 Compare July 13, 2026 17:00
…view (#840)

Storage roots: pairwise distinct-and-non-nested validation
(Paths.ValidateRoots) before LockRoots; correct config_lock.go's
fail-fast claim (exact-path only, nested roots not detected).

Snapshot-distribution guards, all at load time: offsets total vs
trailer item count; non-empty index.pack/index.hash bound to each
other (MPHF keys == pack records) and to the chunk's emptiness;
Trailer().Format verified for both eventstore packs. Close now drains
the raw MPHF wait, so eventless-chunk teardown does no events.pack
metadata I/O — validation lives on the lookup path.

Point fixes: InsertEvents' deferred close-error join reaches the
caller (named return); the warmup tamper tripwire no longer wraps at
eventID==MaxUint32 (uint64 bound); TermsForBytes hard-fails a future
ContractEvent body version to match the SQLite backend instead of
silently indexing contractID-only; the MaxEvents-before-postFilter
paging contract is documented for the #772 wiring; FetchRange's doc
matches the count==0 no-op both implementations have.
@chowbao chowbao force-pushed the chore/fullhistory-hardening-840 branch from 8a10b76 to 604a021 Compare July 13, 2026 17:18
@chowbao chowbao marked this pull request as ready for review July 13, 2026 17:19
@chowbao

chowbao commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@codex[agent] review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 604a021492

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cmd/stellar-rpc/internal/fullhistory/config/config.go Outdated
isAncestorDir built the ancestor prefix as outer+separator, which for
outer "/" produces "//" and matches nothing — ledgers = "/" plus any
sibling root passed ValidateRoots despite the outer tree containing
every other store. Only append the separator when the cleaned path does
not already end in one (true only for the filesystem root).
// holds an exclusive OS lock on a LOCK file under EVERY independently configurable
// storage root — the catalog, each immutable artifact tree, AND the
// hot-storage tree. A second daemon touching any shared root fails fast.
// hot-storage tree. A second daemon pointing at the SAME root path fails fast.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the per-root flocks? RocksDB already takes an exclusive lock when catalog.Open opens the catalog read-write, so a second daemon on the same catalog already fails to start on its own. The flocks only add coverage for the cold artifact trees, and those are write-once: two daemons freezing the same chunk write identical bytes and the atomic rename resolves it, so a shared cold tree is redundant work, not corruption. Suggest dropping them and leaning on the catalog lock.

Same for ValidateRoots: the prune is per-chunk (eligiblePruneOps deletes {root}/{chunkID}/, never a whole root), so a nested root is harmless. The only config that loses data is two roots at the same path, so a one-line distinct-roots check is enough and isAncestorDir/rootNames/the nesting cases can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7cd5416. Verified both premises before cutting: the catalog is RocksDB (catalog.go:42) so a second daemon fails at catalog.Open on its exclusive LOCK, and every whole-directory deletion is per-chunk (BeginHotCreate/DiscardHotChunk remove HotChunkPath(chunkID) only) — nothing ever removes a root. Flocks are gone; LockRoots' load-bearing other half (create missing roots + fsync the new dir chain, the fresh-deploy crash-safety step) survives as PrepareRoots, and RootsToLock is renamed Roots. ValidateRoots is now the one-line distinct-roots check (still on resolved absolute paths so unclean-spelling duplicates are caught); nesting cases, isAncestorDir, and rootNames are deleted, and the daemon contention test now pins the RocksDB-LOCK failure instead of ErrRootLocked.

@chowbao chowbao Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrepareRoots seems good for creating new dirs/roots on startup

ch <- mphfResult{idx: m, err: err}
}()
c.waitMPHF = sync.OnceValues(func() (*mphf, error) {
c.waitMPHFRaw = sync.OnceValues(func() (*mphf, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raw/validated split works, but the leak is avoided by convention: waitMPHF returns (nil, err) on a validation failure, so Close has to remember to use waitMPHFRaw or it would skip m.Close() and leak the handle. Cleaner to separate the handle from the verdict: one load accessor (waitMPHF, no validation) plus an error-only gate (validateMPHF). Then Close always gets the real handle and a nil can't reach it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 14603d1: waitMPHF is now the validation-free load accessor and validateMPHF is a sync.OnceValue error-only gate; Close always gets the real handle, and LookupKeys runs the gate before using it.

chowbao added 2 commits July 13, 2026 14:43
waitMPHF returned (nil, err) on a cross-check failure, so Close had to
remember to drain the raw variant or it would leak the handle. Split
instead into one validation-free load accessor (waitMPHF) and an
error-only gate (validateMPHF): Close always gets the real handle, and
a validation failure can no longer hand it a nil.
catalog.Open already holds RocksDB's exclusive LOCK, so a second daemon
on the same catalog fails to start without any help. The extra flocks
only covered the cold artifact trees — write-once behind the one-write
protocol, where a shared tree means redundant work, not corruption —
and each hot chunk DB carries its own RocksDB LOCK.

LockRoots' other job survives as PrepareRoots: create missing roots and
fsync the new directory chain, so a fresh-deploy crash cannot lose a
tree the synced catalog already references. RootsToLock is renamed
Roots to match.

ValidateRoots shrinks to a distinct-roots check: prune and discard
delete per-chunk paths only, never a whole root, so nested roots lose
nothing and the nesting detection (isAncestorDir, rootNames) goes.

@tamirms tamirms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Verified locally: the tree builds, go vet is clean, and the config, eventstore, daemon, and full e2e suites pass, including the new second-daemon test confirming the RocksDB catalog lock preserves single-process enforcement. One minor nit inline.

if err != nil {
return fmt.Errorf("resolve storage root %q: %w", root, err)
}
if _, dup := seen[abs]; dup {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrepareRoots's seen dedup is redundant: ValidateRoots runs first in runDaemonWith and already rejects any two roots resolving to the same path, so this loop never sees a duplicate. MkdirAll is idempotent anyway, so a repeat would be harmless. Could drop it, minor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped in 4652e37 — the loop is now a plain range with the empty-root skip; the test's repeated-root case stays as a harmless-no-op check.

ValidateRoots runs first and rejects duplicate roots, and MkdirAll +
FsyncNewDirs are idempotent anyway — the seen map guarded nothing.
@chowbao chowbao merged commit a49c218 into feature/full-history Jul 13, 2026
8 of 15 checks passed
@chowbao chowbao deleted the chore/fullhistory-hardening-840 branch July 13, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants