Skip to content

Follow-ups for SMST deferred hashing + copy-on-write historical proofs [#1432]#1447

Merged
DimaOrekhovPS merged 14 commits into
upgrade-v0.2.14from
ak/smst-tree-cache-followups
Jul 16, 2026
Merged

Follow-ups for SMST deferred hashing + copy-on-write historical proofs [#1432]#1447
DimaOrekhovPS merged 14 commits into
upgrade-v0.2.14from
ak/smst-tree-cache-followups

Conversation

@a-kuprin

@a-kuprin a-kuprin commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Base: feat/smst-tree-cache (or merge into #1432)
Branch: ak/smst-tree-cache-followups

Summary

Review follow-ups on top of PR #1432 (SMST deferred hashing + copy-on-write historical proofs). No consensus / root-byte changes; same Merkle semantics.

Fixes

  1. Retained historical proofs no longer hold the write lock across artifact I/O
    After taking a retained COW view, unlock and re-take RLock so ingest and concurrent proofs are not serialized behind getArtifactByNonce.

  2. Durable flush boundaries independent of distributions.jsonl
    Persist count + root to flushed_roots.jsonl on flush. Recover unions that journal with distribution history so a warn-only dist-append failure cannot make an on-chain flush look non-committed after restart. Backfill for stores that only had dist history.

  3. Live-tip deferred hash fill under Lock, proofs under RLock
    ensureHashed runs under write lock only, then retries onto the read-lock fast path. getRoot / GetRootAt / GetFlushedRoot prefer RLock when already hashed.

Profiling / toggles (defaults unchanged for production)

Env Default 0 / false
SMST_DEFERRED_HASH on Eager per-insert hashing (v0.2.14 baseline)
SMST_COW on In-place Insert (no path-copy retain at flush)
SMST_SNAPSHOT_IN_MEMORY_CLONE on Tip PrebuildSnapshot rebuilds from artifacts into the process cache without holding the write lock (v0.2.14 Warm/Prebuild path)
SMST_PARALLEL_HASH on Serial ensureHashed; default fans out deferred fill across GOMAXPROCS

Tip PrebuildSnapshot when count equals the live tip:

Mode Behavior
COW on O(1) retain (usually already done at flush; Prebuild is a no-op)
COW off + SMST_SNAPSHOT_IN_MEMORY_CLONE=1 Deep in-memory clone under write lock → retained
COW off + SMST_SNAPSHOT_IN_MEMORY_CLONE=0 Unlock, rebuild from artifact log → snapshot cache

Cold path (tip already past, nothing retained, committed count) still rebuilds into the cache.

SMST_PARALLEL_HASH accelerates deferred ensureHashed (independent subtrees, min 256 leaves). Eager per-insert path hashing stays serial (parent depends on child).

Profiles: TestSMSTBuildProfile (insert vs GetRoot) and TestSMSTStoreFlush30Profile (N leaves, 30 flushes, snap at flush #10).

Measured — illustrative (N=300k)

Build profile (insert all leaves, then one full GetRoot):

Mode insert getroot total
deferred + multicore ~114 ms ~28 ms ~141 ms
deferred serial ~112 ms ~182 ms ~295 ms
eager + multicore ~681 ms ~0 ~681 ms
eager serial ~679 ms ~0 ~679 ms

Flush30 (COW on; each flush only fills hashes dirtied since last flush):

Mode Ingest
deferred + multicore ~670 ms
deferred serial ~689 ms
eager + multicore ~1.19 s
eager serial ~1.22 s

Multicore helps the big deferred fill (~6× on full-tree GetRoot). Eager is unchanged. Incremental flushes already hash little, so parallel vs serial there is close.

Snapshot modes (deferred, COW off, snap at 1/3):

Mode Snap via
in-memory clone ~6.6 ms retained
artifact rebuild ~95 ms cache
COW on ~1 µs retained

Commits

  1. fix(poc/artifacts): release write lock before retained proof I/O
  2. fix(poc/artifacts): persist flush roots independent of distributions.jsonl
  3. fix(poc/artifacts): fill live-tip hashes under Lock, serve proofs under RLock
  4. feat(poc/artifacts): env toggles for deferred hashing and COW + 30-flush profiles
  5. feat(poc/artifacts): tip Prebuild in-memory clone vs artifact rebuild toggle
  6. feat(poc/artifacts): multicore ensureHashed behind SMST_PARALLEL_HASH

Test plan

  • go test ./poc/artifacts/ -count=1
  • go test ./poc/artifacts/ -race -run 'TestCOW|TestDeferred|TestFlushedRoots|TestSMSTCOW|TestSMSTDefaults|TestParallelHash' -count=1
  • Deferred × parallel profile matrix:
    SMST_PROF_N=300000 SMST_DEFERRED_HASH=1 SMST_PARALLEL_HASH=1 SMST_COW=1 \
      go test ./poc/artifacts/ -run 'TestSMSTBuildProfile|TestSMSTStoreFlush30Profile' -v
    SMST_PROF_N=300000 SMST_DEFERRED_HASH=1 SMST_PARALLEL_HASH=0 SMST_COW=1 \
      go test ./poc/artifacts/ -run 'TestSMSTBuildProfile|TestSMSTStoreFlush30Profile' -v
    SMST_PROF_N=300000 SMST_DEFERRED_HASH=0 SMST_PARALLEL_HASH=1 SMST_COW=1 \
      go test ./poc/artifacts/ -run 'TestSMSTBuildProfile|TestSMSTStoreFlush30Profile' -v
    SMST_PROF_N=300000 SMST_DEFERRED_HASH=0 SMST_PARALLEL_HASH=0 SMST_COW=1 \
      go test ./poc/artifacts/ -run 'TestSMSTBuildProfile|TestSMSTStoreFlush30Profile' -v
  • Confirm production path with env unset: deferred + COW + in-memory clone + parallel hash all on (TestSMSTDefaultsDeferredAndCOW)

Notes for #1432 authors / reviewers

These commits are intended to land on top of #1432 (or be folded into that PR).

kAIPraxisBot and others added 11 commits July 10, 2026 08:59
…ts, up to 3.4x faster ingest

insertAt re-hashed every node on the root->leaf path on each insert, so a shared
upper node was re-hashed once per descendant insert. Record structure and counts
on insert (node.hash = nil) and fill hashes lazily once when the root is needed
(GetRoot / snapshot proof / rebuild) via ensureHashed, hashing each node once per
flush instead of once per insert that passes through it.

Roots and proofs are byte-identical to per-insert hashing (same Merkle function,
different hashing order): a 200k-insert fingerprint over flush roots and sampled
proofs matches the previous implementation exactly, and per-N roots match at
100k / 500k / 1M / 10M.

Hashing mutates nodes, so live-tree read paths that may fill hashes take the
write lock; the live-tip proof path keeps a read-lock fast path once the hashes
are already filled.

Adds TestDeferredHashFingerprint (root/proof determinism guard), live-tip and
historical-rebuild proof-path tests, and an env-gated build profile
(TestSMSTBuildProfile) reproducing the ingest/RAM numbers.
… drop the rebuild-DoS limiter

Historical snapshot proofs rebuilt the whole tree from the artifact log on every
request (O(N) SHA-256), which a validator could turn into a rebuild-DoS by
cycling distinct counts — guarded until now by a per-validator distinct-count
quota. This makes the live tree copy-on-write and serves committed-count proofs
from retained snapshots in O(depth).

- insertCOW rewrites only the root->leaf path and shares untouched subtrees, so a
  captured root stays valid. Roots and proofs are byte-identical to Insert (same
  Merkle function, hashing deferred identically); the on-chain commitment is
  unchanged.
- The store captures a retained snapshot at every committed (flush) count and
  serves its proofs from shared nodes in O(depth). On restart, recover()
  re-captures a snapshot at each committed count in one copy-on-write replay, so
  historical proofs stay O(depth) after a restart.
- A proof at a non-committed count is rejected outright (its root can never match
  the on-chain commitment), so a proof request can no longer trigger a rebuild.
  The rebuild path remains only as a defensive cold-start fallback.
- With the rebuild-DoS surface gone, the per-validator distinct-snapshot-count
  limiter is removed.

Byte-identical roots/proofs verified across the suite; live, retained-historical
and post-restart proof paths plus the concurrent-readers test are green under
-race.
…essions

Byte-identity of eager Insert, copy-on-write insert, and rebuild-from-log
across roots and raw proofs at many N (through depth-expansion boundaries);
served transport proofs verify against the eager root. Plus two regression
tests for the load-test findings this PR addresses: a non-committed proof
count is rejected outright (no O(N) rebuild), and an early committed root is
served from a re-captured retained snapshot in O(depth) after a restart,
byte-identical to the pre-restart root.
…jsonl

Recover used distributionHistory counts as the only durable flush markers, so a
warn-only dist append failure made an on-chain flush look non-committed after
restart. Write count+root to flushed_roots.jsonl on flush, union it on recover,
backfill for pre-roots stores, and test surviving a wiped distributions file.
…ush profiles

SMST_DEFERRED_HASH and SMST_COW default on; set either to 0 for profiling
baselines. When COW is off, pin flushed counts via the process snapshot cache
(WarmSnapshot/PrebuildSnapshot) so early 1/3 commits stay cached without
retained trees. Add a 30-flush early-snapshot profile and coverage for defaults
and COW-off early cache behavior.
… toggle

When COW is off, PrebuildSnapshot can deep-clone under the write lock
(default) or rebuild from artifacts without holding the lock
(SMST_SNAPSHOT_IN_MEMORY_CLONE=0, v0.2.14-style). Refresh follow-up PR notes
and Flush30 profile matrix.
Fan out deferred hash fill across GOMAXPROCS (default on); eager path
hashing stays serial. Add root-equality coverage and deferred×parallel
profile matrix; refresh follow-up PR notes.
@tcharchian tcharchian added this to the v0.2.14 milestone Jul 14, 2026
@tcharchian
tcharchian requested a review from x0152 July 14, 2026 21:30
@DimaOrekhovPS

Copy link
Copy Markdown
Collaborator

/run-integration

@DimaOrekhovPS
DimaOrekhovPS merged commit 817b7ab into upgrade-v0.2.14 Jul 16, 2026
6 checks passed
d-bogdan-engenious pushed a commit to d-bogdan-engenious/gonka that referenced this pull request Jul 16, 2026
x0152 pushed a commit to x0152/gonka that referenced this pull request Jul 17, 2026
gonka-ai#1432] (gonka-ai#1447)

* perf(poc/artifacts): defer SMST hashing to flush — byte-identical roots, up to 3.4x faster ingest

insertAt re-hashed every node on the root->leaf path on each insert, so a shared
upper node was re-hashed once per descendant insert. Record structure and counts
on insert (node.hash = nil) and fill hashes lazily once when the root is needed
(GetRoot / snapshot proof / rebuild) via ensureHashed, hashing each node once per
flush instead of once per insert that passes through it.

Roots and proofs are byte-identical to per-insert hashing (same Merkle function,
different hashing order): a 200k-insert fingerprint over flush roots and sampled
proofs matches the previous implementation exactly, and per-N roots match at
100k / 500k / 1M / 10M.

Hashing mutates nodes, so live-tree read paths that may fill hashes take the
write lock; the live-tip proof path keeps a read-lock fast path once the hashes
are already filled.

Adds TestDeferredHashFingerprint (root/proof determinism guard), live-tip and
historical-rebuild proof-path tests, and an env-gated build profile
(TestSMSTBuildProfile) reproducing the ingest/RAM numbers.

* perf(poc/artifacts): copy-on-write SMST — O(depth) historical proofs, drop the rebuild-DoS limiter

Historical snapshot proofs rebuilt the whole tree from the artifact log on every
request (O(N) SHA-256), which a validator could turn into a rebuild-DoS by
cycling distinct counts — guarded until now by a per-validator distinct-count
quota. This makes the live tree copy-on-write and serves committed-count proofs
from retained snapshots in O(depth).

- insertCOW rewrites only the root->leaf path and shares untouched subtrees, so a
  captured root stays valid. Roots and proofs are byte-identical to Insert (same
  Merkle function, hashing deferred identically); the on-chain commitment is
  unchanged.
- The store captures a retained snapshot at every committed (flush) count and
  serves its proofs from shared nodes in O(depth). On restart, recover()
  re-captures a snapshot at each committed count in one copy-on-write replay, so
  historical proofs stay O(depth) after a restart.
- A proof at a non-committed count is rejected outright (its root can never match
  the on-chain commitment), so a proof request can no longer trigger a rebuild.
  The rebuild path remains only as a defensive cold-start fallback.
- With the rebuild-DoS surface gone, the per-validator distinct-snapshot-count
  limiter is removed.

Byte-identical roots/proofs verified across the suite; live, retained-historical
and post-restart proof paths plus the concurrent-readers test are green under
-race.

* test(poc/artifacts): differential Insert==COW==rebuild + finding regressions

Byte-identity of eager Insert, copy-on-write insert, and rebuild-from-log
across roots and raw proofs at many N (through depth-expansion boundaries);
served transport proofs verify against the eager root. Plus two regression
tests for the load-test findings this PR addresses: a non-committed proof
count is rejected outright (no O(N) rebuild), and an early committed root is
served from a re-captured retained snapshot in O(depth) after a restart,
byte-identical to the pre-restart root.

* fix(poc/artifacts): release write lock before retained proof I/O

* fix(poc/artifacts): persist flush roots independent of distributions.jsonl

Recover used distributionHistory counts as the only durable flush markers, so a
warn-only dist append failure made an on-chain flush look non-committed after
restart. Write count+root to flushed_roots.jsonl on flush, union it on recover,
backfill for pre-roots stores, and test surviving a wiped distributions file.

* fix(poc/artifacts): fill live-tip hashes under Lock, serve proofs under RLock

* feat(poc/artifacts): env toggles for deferred hashing and COW + 30-flush profiles

SMST_DEFERRED_HASH and SMST_COW default on; set either to 0 for profiling
baselines. When COW is off, pin flushed counts via the process snapshot cache
(WarmSnapshot/PrebuildSnapshot) so early 1/3 commits stay cached without
retained trees. Add a 30-flush early-snapshot profile and coverage for defaults
and COW-off early cache behavior.

* feat(poc/artifacts): tip Prebuild in-memory clone vs artifact rebuild toggle

When COW is off, PrebuildSnapshot can deep-clone under the write lock
(default) or rebuild from artifacts without holding the lock
(SMST_SNAPSHOT_IN_MEMORY_CLONE=0, v0.2.14-style). Refresh follow-up PR notes
and Flush30 profile matrix.

* feat(poc/artifacts): multicore ensureHashed behind SMST_PARALLEL_HASH

Fan out deferred hash fill across GOMAXPROCS (default on); eager path
hashing stays serial. Add root-equality coverage and deferred×parallel
profile matrix; refresh follow-up PR notes.

---------

Co-authored-by: kAIPraxisBot <272124397+kAIPraxisBot@users.noreply.github.com>
Co-authored-by: akup <ak@neonavigation.com>
Co-authored-by: DimaOrekhovPS <dima.orekhov@productscience.ai>
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.

6 participants