Skip to content

Remove ringbuffer usage for ledger mutations and range reads - #8405

Merged
Amaury Chamayou (achamayou) merged 22 commits into
mainfrom
agents/ledger-ringbuffer-removal-implementation
Oct 6, 2026
Merged

Amaury Chamayou (achamayou) merged 22 commits into
mainfrom
agents/ledger-ringbuffer-removal-implementation

Conversation

@eddyashton

@eddyashton Eddy Ashton (eddyashton) commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

Motivation

Next step of the ringbuffer-removal plan, after #8395 and #8394: take all ledger traffic off the host/enclave ringbuffer. This covers the five mutation messages (ledger_init, ledger_append, ledger_truncate, ledger_commit, ledger_open) and the range-read request/response messages (ledger_get_range, ledger_entry_range, ledger_no_entry_range), replacing serialised messages with typed C++ interfaces and owned values.

Node-to-node transport and tick remain on the ringbuffer and are out of scope.

Implementation summary

Typed interfaces. consensus::AbstractLedgerWriter (init/append/truncate/commit/open) and consensus::AbstractLedgerReader (get_range with a typed callback delivering an owned LedgerRangeResult). LedgerEnclave, NodeState recovery, and the historical StateCache take these instead of a ringbuffer writer.

Host LedgerSubsystem (src/host/ledger_subsystem.h) wraps the existing synchronous Ledger:

  • Mutations, range classification, and reads of uncommitted state run FIFO on one ccf::tasks::OrderedTasks lane on the main job board. Each append owns its entry bytes before submission. This preserves the single-FIFO ordering the ringbuffer provided (append before commit/truncate, init before first read, open after recovery mutations).
  • Reads wholly within committed files are dispatched as ordinary tasks so they do not hold up the lane; their file access still takes the Ledger state lock, as the previous libuv-threadpool read did.
  • Nothing in the subsystem blocks on another task.
  • Shutdown drains the lane after the enclave threads join and before enclave_shutdown_tasks() abandons queued work, then closes a WorkerShutdownGate and waits for in-flight callbacks. Accepted mutations complete; queued read callbacks are suppressed during the drain.

Backpressure. Removing ledger messages also removed their implicit blocking byte bound. Pending append bytes are now counted through write completion or cancellation, with threshold-crossing logs and memory.circuit_size (16MB default) as a soft admission threshold:

  • The frontend asks consensus should_apply_backpressure(), combining local ledger backlog with the existing uncommitted-entry count. Application and governance requests, including reads, receive the existing 503 TooManyPendingTransactions on overloaded nodes of any role. /node remains exempt; already-admitted work is not rejected.
  • When the ledger is backlogged, authenticated AppendEntries are dropped before term/log processing, including heartbeats. There is no overload NACK, timer reset, retained deferred message, or worker wait. Normal periodic probes and existing log-mismatch retries recover gaps after IO catches up. Other consensus messages continue to be processed; uncommitted-count pressure alone does not drop replication.
  • This deliberately trades blocking for request shedding and packet loss. It is not a hard total-memory bound or ack-after-persist/fsync guarantee: in-flight work, internal operations, exempt writes and non-append queues can exceed it. Long stalls may cause elections and catch-up retransmission costs. Blocking append submission could deadlock the workers needed to drain IO.

Operator-facing behaviour is documented in Resource Usage, with corrected configuration descriptions. No new setting or separate CHANGELOG entry was added.

Range results distinguish Found, NotFound, and TooLarge. Previously an entry exceeding the read budget produced no_entry_range, which recovery treated as end-of-ledger and silently completed early. Recovery now fails explicitly; historical queries log and drop the request. Only reachable with a ledger written before ledger.max_transaction_size existed, so no CHANGELOG entry. The read budget stays memory.max_msg_size - ledger_range_response_metadata_size; no configuration changes. A ledger-named setting will replace it when memory.* is removed.

Ledger::init fix. init un-commits later files so they can be replayed into, but did not lower end_of_committed_files_idx, so those files were still classified as committed for readers. It now does. (Latent in the old design too.)

Node-to-node ordering (second commit). Node messages still arrive on the ringbuffer, on a different queue from ledger mutations, which lost the emission order that AppendEntries attachment relied on: the host could read the ledger before the append had landed and dropped the AE (observed 5-8 times per e2e run at [fail]). Each outbound node message is now submitted to the ledger lane, where any entries are read and the frame assembled as owned bytes, then queued as typed data for the libuv thread, which drains it on a 1ms timer (the same cadence the ringbuffer is drained at). Address and close updates take the same path so their order relative to sends is preserved.

The ordering argument: on the enclave thread, ledger->put_entry(N) pushes to the lane, then writes node_outbound(AE ...N]) to the ringbuffer (raft.h replicate()); the host uv thread drains that ringbuffer message and pushes a lane action; SubTaskQueue::push is mutex-ordered, so the lane sees append(N) before the AE action; the lane is FIFO, so assemble_frame reads after the append has landed. The host applies everything in enclave emission order, as the single ringbuffer did. Heartbeats queue behind ledger IO as before. This routing is temporary until node-to-node transport leaves the ringbuffer.

Also: host may now depend on tasks (a leaf component); ds/messaging.h is included explicitly where ledger.h previously provided it transitively; node_connections_test gains a real LedgerSubsystem on a test JobBoard.

The base now includes #8404's ingress-only dispatch loop and worker execution baseline.

Safety and compatibility

  • Consensus/ledger ordering: preserved by construction (one FIFO lane; node messages funnelled into it). Wire formats are unchanged; overloaded receivers deliberately drop AppendEntries as described above.
  • Durability at shutdown: preserved by draining the lane before closing the gate.
  • Committed-file reads vs init: serialised on the Ledger state lock as before; classification is now truthful after init.
  • Failure behaviour: ledger read/write exceptions remain fail-fast (task execution aborts, as the uv-loop handler did). LedgerEnclave throws if the subsystem rejects a mutation after shutdown has begun.
  • Deliberate change: oversized persisted entry fails recovery explicitly rather than truncating it silently.
  • No new public setting or data format. memory.circuit_size also supplies the append-admission threshold; request backpressure now applies regardless of role. Ledger file and node-to-node wire formats are unchanged.

Latest validation after merging main: nine affected unit suites (ledger_test, node_connections_test, raft_test, raft_enclave_test, historical_queries_test, indexing_test, open_service_test, frontend_test, node_frontend_test), host/enclave/application builds, recovery and logging e2e, all scripts/ci-checks.sh checks, and Sphinx with warnings as errors. Initial e2e attempts encountered multi-second fsync stalls and election-related failures; unchanged retries passed. An independent adversarial backpressure review found no blocking issue after checking outbound FIFO ordering; small lock-fast-path and documentation clarifications were applied. Per-peer replication windows and explicit durability-based acknowledgements remain out of scope.

Replace the ledger_init/append/truncate/commit/open and ledger_get_range/
entry_range/no_entry_range ringbuffer messages with typed ledger interfaces.
A host-owned LedgerSubsystem runs mutations, range classification and reads
of uncommitted state in FIFO order on one OrderedTasks lane, and reads that
lie wholly within committed files as ordinary concurrent tasks. Entries are
moved into owned storage before submission and results are delivered as
owned values through typed callbacks.

Ledger::init now lowers the committed-file classification boundary when it
un-commits later files, so a read classified after init can never target a
file about to be replayed into. Range results distinguish an absent range
from an entry exceeding the read budget; recovery fails explicitly on the
latter rather than treating it as end-of-ledger.

The read budget remains derived from memory.max_msg_size so no
configuration changes. host may now depend on tasks (a leaf component).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ledger mutations now reach the host through the ledger OrderedTasks lane
rather than the ringbuffer, but node-to-node messages still arrive on the
ringbuffer. The two queues lost the emission order the enclave relies on: an
AppendEntries message could be processed before the append it refers to had
been applied, and the host dropped it.

Restore a single order by submitting each outbound node message to the same
lane as ledger mutations. The lane action reads any ledger entries and
assembles the complete frame; the result is queued for the libuv thread,
which owns the sockets and drains the queue on the existing 1ms cadence.
Address and close updates take the same path so their order relative to
sends is preserved. This is temporary until node-to-node transport leaves
the ringbuffer.

Also fix node_connections_test for the Ledger constructor change and the
messaging.h include that ledger.h no longer provides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review findings on the typed ledger subsystem:

- shutdown() ran after the enclave task workers had exited and only closed
  the gate, so an append or commit which had been accepted but not yet
  executed was never written. The ringbuffer design drained remaining
  messages before stopping the loop. Drain the ordered lane synchronously in
  shutdown() before closing the gate.

- Reads of committed files bypassed the ledger state lock. A read already
  dispatched before init() could then observe a file being un-committed and
  rewritten. The libuv threadpool read previously took that lock; do so
  again, so committed reads are dispatched off the lane but their file access
  is serialised against mutations as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@eddyashton
Eddy Ashton (eddyashton) marked this pull request as ready for review September 18, 2026 15:48
@eddyashton
Eddy Ashton (eddyashton) requested a review from a team as a code owner September 18, 2026 15:48
Copilot AI lite review requested due to automatic review settings September 18, 2026 15:48

Copilot AI 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.

🟡 Changes recommended

Shutdown can continue recovery callbacks after enclave threads stop, and new oversized-entry messages reference a nonexistent configuration setting.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Moves ledger mutations and range reads from ringbuffer messages to typed, task-backed interfaces, while preserving ledger ordering and node-to-node AppendEntries assembly.

Changes:

  • Adds typed ledger reader/writer interfaces and FIFO host processing.
  • Reworks node transport ordering and ledger shutdown handling.
  • Updates ledger, consensus, historical-query, indexing, and host tests.

Custom instructions used

  • .github/copilot-instructions.md
  • .github/instructions/reviewing.instructions.md
File summaries
File Description
src/host/ledger_subsystem.h Implements typed ledger operations and task ordering.
src/host/ledger.h Removes ringbuffer handlers and adds range status handling.
src/host/node_connections.h Orders outbound node frames with ledger operations.
src/host/run.cpp Constructs and shuts down the ledger subsystem.
src/node/node_state.h Uses typed ledger recovery and mutation calls.
src/node/historical_queries.h Uses typed historical range reads.
src/consensus/ledger_enclave.h Sends owned typed ledger mutations.
src/consensus/ledger_enclave_types.h Defines typed ledger interfaces and results.
src/node/rpc/ledger_interface.h Defines the combined ledger subsystem interface.
src/enclave/enclave.h Passes and shuts down the ledger subsystem.
src/enclave/main.cpp Updates enclave creation interface.
src/enclave/entry_points.h Updates enclave entry-point declaration.
src/node/rpc/file_serving_handlers.h Uses the renamed ledger subsystem interface.
src/consensus/test/ledger_stub.h Adds typed ledger test stubs.
src/consensus/aft/test/enclave.cpp Tests typed mutation submission.
src/node/test/historical_queries.cpp Migrates historical-query tests.
src/indexing/test/indexing.cpp Migrates indexing tests.
src/host/test/ledger.cpp Adds subsystem ordering and lifecycle tests.
src/host/test/node_connections.cpp Exercises ledger-backed node connections.
scripts/source-dependencies.json Records the host-to-tasks dependency.
CMakeLists.txt Updates affected test link dependencies.
Review details
  • Files reviewed: 21/21 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/host/ledger_subsystem.h
Comment thread src/node/historical_queries.h Outdated
Comment thread src/node/node_state.h
performance-enum-size on LedgerRangeStatus, a redundant access specifier in
NodeConnectionsImpl, and std::move of a const shared_ptr in Enclave.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ages

Review findings on #8405:

- The shutdown drain executed queued range reads as well as mutations. A
  recovery read callback submits the next batch, so stopping mid-recovery
  could keep the host thread recovering the whole ledger after the enclave
  threads had joined. Set a draining flag before the drain: it rejects new
  submissions and makes queued reads (and committed-read tasks) skip their
  callback, while mutations still reach disk.

- The oversized-entry log messages named ledger.max_read_size, which is not
  a setting. Describe the derived budget instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The test must SIGTERM the primary while it is reading the private ledger,
which takes about 0.8s for the 2000 entries the test writes. It did not
start polling until recover_with_shares() returned, which is 0.3-1.5s after
the final share is accepted depending on client request latency, so on a
slow client the read had finished before the first poll. Measured against
main with identical read start (about 180ms after the final share) and
replay rate (about 3.5k entries/s): the product behaviour is the same; only
the client-side slack differs.

Submit the shares from a thread and poll the primary immediately over a
pre-established connection, so the first observation is bounded by the poll
round trip rather than by the share-submission tail. Poll only the primary:
followers begin reading later, once the primary broadcasts the ledger
secrets, and need not be observed for this scenario.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@eddyashton
Eddy Ashton (eddyashton) force-pushed the agents/ledger-ringbuffer-removal-implementation branch 2 times, most recently from eb0f257 to f3cf10e Compare September 21, 2026 11:13
Extract the body of the leader-only branch at the end of private ledger
recovery into open_recovered_service(), which takes only the transaction,
share manager and service key it uses. It is now testable without a
NodeState harness, of which the repository has none.

open_recovered_service_test covers a store in the recovering state: the
service becomes OPEN, submitted shares are cleared, fresh shares are issued
and the previous identity is endorsed; and that it throws for any status
other than WAITING_FOR_RECOVERY_SHARES, which is the at-most-once guard
that prevents a second node from opening a service another has already
opened.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The test tried to SIGTERM the primary inside the window where it is reading
the private ledger, by polling quickly enough. Nothing guaranteed that: the
window is under a second and shrank relative to the client's turnaround
between accepting the final share and the first poll, so the test failed
deterministically in CI.

Instead, submit the shares to the primary and SIGTERM it as soon as the final
share is accepted, then assert the outcome: a new view is elected, every
survivor reaches PartOfNetwork, the service is open, and no survivor died
attempting a second opening. Whether the stop notice lands before or after
the primary finishes reading is a race the test no longer needs to win: if
the primary wrote the opening transaction and then stepped down before it
replicated, the new leader rolls it back and opens the service itself, which
both local runs exercised. A node which is not primary refusing to open, and
opening being at-most-once, are covered by open_recovered_service_test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@eddyashton
Eddy Ashton (eddyashton) force-pushed the agents/ledger-ringbuffer-removal-implementation branch from f3cf10e to c240d85 Compare September 21, 2026 11:14
PreviousServiceIdentityEndorsement became a map keyed by IdentityType in
#8401, so look up the CLASSICAL endorsement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Description

Comparing 5 available runs from this branch (#8405) against the trend of the last 30 main runs.

Each chart plots every benchmark as an axis, with values normalized so 100 is the EWMA baseline of recent main runs, using a 7-run half-life. The 5 orange branch lines run from the oldest (faintest) to the latest (darkest and thickest); the darker blue band is the main baseline +/- 1 std dev and the lighter blue band around it is +/- 2 std dev.

Axis labels show the latest branch value and its difference from the main EWMA baseline, where 0% is on the baseline. They are coloured green where the latest run improves on the baseline, red where it regresses, and grey where the difference is within one std dev of the baseline (within noise). Higher is better for throughput and rate, lower for latency and memory.

A benchmark which does not exist on main yet has no baseline of its own, so its earliest available run from this branch is used as its reference and its band is measured across this branch's runs. Its axis is normalized, scaled and coloured like any other, but the comparison is against this branch rather than against main.

Throughput (tx/s)

---
config:
  radar:
    width: 620
    height: 620
    marginTop: 90
    marginRight: 220
    marginBottom: 60
    marginLeft: 220
    axisLabelFactor: 1.12
    curveTension: 0.08
  theme: base
  themeCSS: |
    .radarCurve-0{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-1{fill:color-mix(in srgb, #62B5E5 40%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-2{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-3{fill:var(--color-canvas-default,var(--bgColor-default,#fff))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarAxisLabel,.radarTitle{fill:var(--color-fg-default,var(--fgColor-default,#111827))!important;color:var(--color-fg-default,var(--fgColor-default,#111827))!important}
    .radarCurve-4{stroke-width:1.5px!important;stroke-opacity:0.20!important}
    .radarCurve-5{stroke-width:1.5px!important;stroke-opacity:0.30!important}
    .radarCurve-6{stroke-width:1.5px!important;stroke-opacity:0.40!important}
    .radarCurve-7{stroke-width:1.5px!important;stroke-opacity:0.50!important}
    .radarCurve-8{stroke-width:1.75px!important;stroke-opacity:1.00!important}
    .radarAxisLabel:nth-of-type(1){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(2){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(3){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(4){fill:#2DA44E!important}
    .radarAxisLabel:nth-of-type(5){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(6){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(7){fill:#808A94!important}
  themeVariables:
    cScale0: "#62B5E5"
    cScale1: "#62B5E5"
    cScale2: "#62B5E5"
    cScale3: "#62B5E5"
    cScale4: "#F97316"
    cScale5: "#F97316"
    cScale6: "#F97316"
    cScale7: "#F97316"
    cScale8: "#F97316"
    radar:
      axisColor: "#9CA3AF"
      graticuleColor: "#E5E7EB"
      graticuleOpacity: 0
      axisStrokeWidth: 1
      curveOpacity: 0
---
radar-beta
  axis b0["Basic Blocking 100ms: 3,100 tx/s ▬ 0%"]
  axis b1["Basic Blocking 20ms: 15,364 tx/s ▬ 0%"]
  axis b2["Basic Blocking 2ms: 50,761 tx/s ▬ +1%"]
  axis b3["Basic JS: 16,560 tx/s ▲ 3%"]
  axis b4["Historical Queries: 1,033,417 tx/s ▬ +6%"]
  axis b5["L…g Certificate Blocking: 28,889 tx/s ▬ 0%"]
  axis b6["Logging JWT Blocking: 15,287 tx/s ▬ 0%"]
  curve stddev2_high["main EWMA + 2 std dev"]{100.39, 100.30, 113.09, 104.62, 111.34, 100.42, 100.54}
  curve stddev1_high["main EWMA + 1 std dev"]{100.19, 100.15, 106.54, 102.31, 105.67, 100.21, 100.27}
  curve stddev1_low["main EWMA - 1 std dev"]{99.81, 99.85, 93.46, 97.69, 94.33, 99.79, 99.73}
  curve stddev2_low["main EWMA - 2 std dev"]{99.61, 99.70, 86.91, 95.38, 88.66, 99.58, 99.46}
  curve branch_0["#8405 (4 runs earlier)"]{99.93, 99.84, 99.94, 100.02, 102.34, 102.35, 100.34}
  curve branch_1["#8405 (3 runs earlier)"]{100.18, 100.02, 99.14, 100.57, 100.66, 99.97, 99.92}
  curve branch_2["#8405 (2 runs earlier)"]{99.82, 99.85, 102.49, 101.08, 100.40, 99.79, 99.88}
  curve branch_3["#8405 (1 run earlier)"]{99.85, 99.88, 101.54, 100.47, 102.32, 99.58, 99.98}
  curve branch_4["#8405"]{100.09, 100.06, 100.58, 102.61, 105.53, 100.05, 99.81}
  graticule polygon
  max 123
  min 77
  ticks 0
  showLegend false
Loading

Latency (ms)

---
config:
  radar:
    width: 620
    height: 620
    marginTop: 90
    marginRight: 220
    marginBottom: 60
    marginLeft: 220
    axisLabelFactor: 1.12
    curveTension: 0.08
  theme: base
  themeCSS: |
    .radarCurve-0{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-1{fill:color-mix(in srgb, #62B5E5 40%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-2{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-3{fill:var(--color-canvas-default,var(--bgColor-default,#fff))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarAxisLabel,.radarTitle{fill:var(--color-fg-default,var(--fgColor-default,#111827))!important;color:var(--color-fg-default,var(--fgColor-default,#111827))!important}
    .radarCurve-4{stroke-width:1.5px!important;stroke-opacity:0.20!important}
    .radarCurve-5{stroke-width:1.5px!important;stroke-opacity:0.30!important}
    .radarCurve-6{stroke-width:1.5px!important;stroke-opacity:0.40!important}
    .radarCurve-7{stroke-width:1.5px!important;stroke-opacity:0.50!important}
    .radarCurve-8{stroke-width:1.75px!important;stroke-opacity:1.00!important}
    .radarAxisLabel:nth-of-type(1){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(2){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(3){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(4){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(5){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(6){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(7){fill:#808A94!important}
  themeVariables:
    cScale0: "#62B5E5"
    cScale1: "#62B5E5"
    cScale2: "#62B5E5"
    cScale3: "#62B5E5"
    cScale4: "#F97316"
    cScale5: "#F97316"
    cScale6: "#F97316"
    cScale7: "#F97316"
    cScale8: "#F97316"
    radar:
      axisColor: "#9CA3AF"
      graticuleColor: "#E5E7EB"
      graticuleOpacity: 0
      axisStrokeWidth: 1
      curveOpacity: 0
---
radar-beta
  axis b0["Basic Blocking 100ms: 98 ms ▬ 0%"]
  axis b1["Basic Blocking 20ms: 19 ms ▬ 0%"]
  axis b2["Basic Blocking 2ms: 5 ms ▬ -3%"]
  axis b3["Basic JS: 19 ms ▬ +1%"]
  axis b4["Historical Queries: 30 ms ▬ -3%"]
  axis b5["Logging Certificate Blocking: 19 ms ▬ 0%"]
  axis b6["Logging JWT Blocking: 19 ms ▬ 0%"]
  curve stddev2_high["main EWMA + 2 std dev"]{100.93, 100.00, 118.37, 106.66, 112.89, 100.00, 100.00}
  curve stddev1_high["main EWMA + 1 std dev"]{100.47, 100.00, 109.19, 103.33, 106.44, 100.00, 100.00}
  curve stddev1_low["main EWMA - 1 std dev"]{99.53, 100.00, 90.81, 96.67, 93.56, 100.00, 100.00}
  curve stddev2_low["main EWMA - 2 std dev"]{99.07, 100.00, 81.63, 93.34, 87.11, 100.00, 100.00}
  curve branch_0["#8405 (4 runs earlier)"]{100.83, 100.00, 116.93, 100.65, 100.61, 100.00, 100.00}
  curve branch_1["#8405 (3 runs earlier)"]{99.81, 100.00, 97.44, 100.65, 100.61, 100.00, 100.00}
  curve branch_2["#8405 (2 runs earlier)"]{99.81, 100.00, 97.44, 100.65, 100.61, 100.00, 100.00}
  curve branch_3["#8405 (1 run earlier)"]{99.81, 100.00, 97.44, 100.65, 97.36, 100.00, 100.00}
  curve branch_4["#8405"]{99.81, 100.00, 97.44, 100.65, 97.36, 100.00, 100.00}
  graticule polygon
  max 132
  min 68
  ticks 0
  showLegend false
Loading

Memory (bytes)

---
config:
  radar:
    width: 620
    height: 620
    marginTop: 90
    marginRight: 220
    marginBottom: 60
    marginLeft: 220
    axisLabelFactor: 1.12
    curveTension: 0.08
  theme: base
  themeCSS: |
    .radarCurve-0{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-1{fill:color-mix(in srgb, #62B5E5 40%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-2{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-3{fill:var(--color-canvas-default,var(--bgColor-default,#fff))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarAxisLabel,.radarTitle{fill:var(--color-fg-default,var(--fgColor-default,#111827))!important;color:var(--color-fg-default,var(--fgColor-default,#111827))!important}
    .radarCurve-4{stroke-width:1.5px!important;stroke-opacity:0.20!important}
    .radarCurve-5{stroke-width:1.5px!important;stroke-opacity:0.30!important}
    .radarCurve-6{stroke-width:1.5px!important;stroke-opacity:0.40!important}
    .radarCurve-7{stroke-width:1.5px!important;stroke-opacity:0.50!important}
    .radarCurve-8{stroke-width:1.75px!important;stroke-opacity:1.00!important}
    .radarAxisLabel:nth-of-type(1){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(2){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(3){fill:#E5484D!important}
    .radarAxisLabel:nth-of-type(4){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(5){fill:#2DA44E!important}
    .radarAxisLabel:nth-of-type(6){fill:#2DA44E!important}
    .radarAxisLabel:nth-of-type(7){fill:#808A94!important}
  themeVariables:
    cScale0: "#62B5E5"
    cScale1: "#62B5E5"
    cScale2: "#62B5E5"
    cScale3: "#62B5E5"
    cScale4: "#F97316"
    cScale5: "#F97316"
    cScale6: "#F97316"
    cScale7: "#F97316"
    cScale8: "#F97316"
    radar:
      axisColor: "#9CA3AF"
      graticuleColor: "#E5E7EB"
      graticuleOpacity: 0
      axisStrokeWidth: 1
      curveOpacity: 0
---
radar-beta
  axis b0["Basic Blocking 100ms: 88.2 MiB ▬ -1%"]
  axis b1["Basic Blocking 20ms: 89.6 MiB ▬ 0%"]
  axis b2["Basic Blocking 2ms: 93.7 MiB ▲ 3%"]
  axis b3["Basic JS: 96.5 MiB ▬ 0%"]
  axis b4["Historical Queries: 139 MiB ▼ 4%"]
  axis b5["Logging Certificate Blocking: 113 MiB ▼ 1%"]
  axis b6["Logging JWT Blocking: 87.1 MiB ▬ -1%"]
  curve stddev2_high["main EWMA + 2 std dev"]{102.54, 102.58, 102.35, 101.80, 101.11, 101.73, 103.17}
  curve stddev1_high["main EWMA + 1 std dev"]{101.27, 101.29, 101.18, 100.90, 100.55, 100.86, 101.58}
  curve stddev1_low["main EWMA - 1 std dev"]{98.73, 98.71, 98.82, 99.10, 99.45, 99.14, 98.42}
  curve stddev2_low["main EWMA - 2 std dev"]{97.46, 97.42, 97.65, 98.20, 98.89, 98.27, 96.83}
  curve branch_0["#8405 (4 runs earlier)"]{98.66, 98.14, 101.83, 100.84, 99.00, 99.29, 99.33}
  curve branch_1["#8405 (3 runs earlier)"]{99.14, 99.26, 98.26, 102.73, 96.27, 99.34, 98.22}
  curve branch_2["#8405 (2 runs earlier)"]{102.80, 99.12, 100.62, 99.97, 95.91, 102.07, 99.34}
  curve branch_3["#8405 (1 run earlier)"]{99.17, 100.57, 99.49, 99.60, 96.34, 99.81, 99.48}
  curve branch_4["#8405"]{98.85, 100.27, 103.08, 99.85, 96.00, 99.01, 99.16}
  graticule polygon
  max 108
  min 91
  ticks 0
  showLegend false
Loading

Rate (ops/s)

---
config:
  radar:
    width: 620
    height: 620
    marginTop: 90
    marginRight: 220
    marginBottom: 60
    marginLeft: 220
    axisLabelFactor: 1.12
    curveTension: 0.08
  theme: base
  themeCSS: |
    .radarCurve-0{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-1{fill:color-mix(in srgb, #62B5E5 40%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-2{fill:color-mix(in srgb, #62B5E5 13%, var(--color-canvas-default,var(--bgColor-default,#fff)))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarCurve-3{fill:var(--color-canvas-default,var(--bgColor-default,#fff))!important;fill-opacity:1!important;stroke:none!important;stroke-width:0!important}
    .radarAxisLabel,.radarTitle{fill:var(--color-fg-default,var(--fgColor-default,#111827))!important;color:var(--color-fg-default,var(--fgColor-default,#111827))!important}
    .radarCurve-4{stroke-width:1.5px!important;stroke-opacity:0.20!important}
    .radarCurve-5{stroke-width:1.5px!important;stroke-opacity:0.30!important}
    .radarCurve-6{stroke-width:1.5px!important;stroke-opacity:0.40!important}
    .radarCurve-7{stroke-width:1.5px!important;stroke-opacity:0.50!important}
    .radarCurve-8{stroke-width:1.75px!important;stroke-opacity:1.00!important}
    .radarAxisLabel:nth-of-type(1){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(2){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(3){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(4){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(5){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(6){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(7){fill:#808A94!important}
    .radarAxisLabel:nth-of-type(8){fill:#2DA44E!important}
    .radarAxisLabel:nth-of-type(9){fill:#808A94!important}
  themeVariables:
    cScale0: "#62B5E5"
    cScale1: "#62B5E5"
    cScale2: "#62B5E5"
    cScale3: "#62B5E5"
    cScale4: "#F97316"
    cScale5: "#F97316"
    cScale6: "#F97316"
    cScale7: "#F97316"
    cScale8: "#F97316"
    radar:
      axisColor: "#9CA3AF"
      graticuleColor: "#E5E7EB"
      graticuleOpacity: 0
      axisStrokeWidth: 1
      curveOpacity: 0
---
radar-beta
  axis b0["CCF c…n context lifecycle: 21,441 ops/s ▬ 0%"]
  axis b1["CCF fresh JS invocation: 19,822 ops/s ▬ +2%"]
  axis b2["CHAMP get: 64,877,879 ops/s ▬ +1%"]
  axis b3["CHAMP put: 8,298,251 ops/s ▬ +2%"]
  axis b4["KV deserialisation: 2,766,252 ops/s ▬ +1%"]
  axis b5["KV serialisation: 2,411,963 ops/s ▬ -2%"]
  axis b6["KV s…t deserialisation: 6,301 ops/s ▬ +2%"]
  axis b7["KV snapshot serialisation: 5,150 ops/s ▲ 7%"]
  axis b8["Q…S s…d c…t lifecycle: 26,579 ops/s ▬ +2%"]
  curve stddev2_high["main EWMA + 2 std dev"]{105.63, 105.66, 106.86, 105.85, 105.36, 106.22, 106.25, 108.79, 105.25}
  curve stddev1_high["main EWMA + 1 std dev"]{102.82, 102.83, 103.43, 102.92, 102.68, 103.11, 103.12, 104.40, 102.63}
  curve stddev1_low["main EWMA - 1 std dev"]{97.18, 97.17, 96.57, 97.08, 97.32, 96.89, 96.88, 95.60, 97.37}
  curve stddev2_low["main EWMA - 2 std dev"]{94.37, 94.34, 93.14, 94.15, 94.64, 93.78, 93.75, 91.21, 94.75}
  curve branch_0["#8405 (4 runs earlier)"]{102.09, 101.74, 102.73, 103.61, 102.66, 100.32, 102.12, 101.55, 101.55}
  curve branch_1["#8405 (3 runs earlier)"]{100.69, 101.33, 98.98, 100.19, 99.84, 100.32, 103.16, 102.34, 100.24}
  curve branch_2["#8405 (2 runs earlier)"]{99.45, 101.04, 98.95, 96.75, 100.92, 101.57, 100.26, 99.79, 99.13}
  curve branch_3["#8405 (1 run earlier)"]{100.54, 101.93, 101.94, 97.88, 100.11, 100.82, 101.23, 101.61, 100.93}
  curve branch_4["#8405"]{100.41, 101.68, 100.71, 102.14, 100.67, 98.39, 101.66, 106.91, 101.66}
  graticule polygon
  max 115
  min 85
  ticks 0
  showLegend false
Loading

@eddyashton

Copy link
Copy Markdown
Member Author

Copilot Review

Copilot AI commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Copilot Review

Changes recommended

[P1] Drain accepted ledger mutations before shutting down the job board - src/host/run.cpp:576-577.

run_enclave_threads() joins workers and then calls enclave_shutdown_tasks() at line 362, which terminally shuts down the main job board. That cancels the registered ledger OrderedTasks lane and discards its pending actions (src/tasks/ordered_tasks.cpp:121-126). The subsequent ledger_subsystem->shutdown() therefore finds an empty queue: accepted appends/commits still pending when workers stop never reach disk. In particular, a single-node service can lose committed transactions during graceful shutdown.

Drain the ledger lane after worker joins but before enclave_shutdown_tasks(). Add regression coverage for this production shutdown sequence; the current shutdown tests invoke subsystem shutdown before board cancellation and miss it.

No other high-confidence findings. All completed head checks are successful, including ASAN/TSAN. Local ASCII and diff-whitespace checks passed; no files changed or build/unit tests run. Automated parallel validation reported no changes and skipped execution.

Custom instructions used

  • .github/copilot-instructions.md (provided repository instructions)
  • .github/instructions/reviewing.instructions.md
  • .github/skills/formatting-and-linting/SKILL.md

enclave_shutdown_tasks() (added by #8420) shuts down the main job board,
which abandons the pending actions of every registered OrderedTasks lane,
including the ledger lane. The host called LedgerSubsystem::shutdown()
after it, so the drain always found an empty queue and mutations which
append()/commit() had accepted were silently discarded on graceful stop.
The ringbuffer design drained remaining ledger messages before stopping
the loop, so this was a regression.

Drain the ledger subsystem inside run_enclave_threads, after the enclave
threads join and before enclave_shutdown_tasks(), and document the
ordering requirement at both the call site and on shutdown().

Remove the shutdown() call from ~Enclave: the Enclave is never destroyed
on the normal exit path, so it only looked like a safety net.

Extend the shutdown unit test with the production call order, and add a
test which pins the hazard (board shutdown first loses the mutations) so
the ordering comments cannot go stale unnoticed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@eddyashton

Copy link
Copy Markdown
Member Author

[P1] Drain accepted ledger mutations before shutting down the job board

Confirmed and fixed in f2985f2.

The mechanism is exactly as described: enclave_shutdown_tasks() -> JobBoard::shutdown() -> OrderedTasks::on_shutdown() -> take_pending(), which released the ledger lane's queued actions before LedgerSubsystem::shutdown() ran, so the drain always saw an empty queue. The drain was correct when written; enclave_shutdown_tasks() arrived via #8420 and was merged in afterwards, and the existing shutdown tests called shutdown() without a preceding board shutdown so they never exercised the production order.

Changes:

  • run_enclave_threads now drains the ledger subsystem after the enclave threads join and before enclave_shutdown_tasks(), with comments at the call site and on LedgerSubsystem::shutdown() stating the ordering requirement and why.
  • Removed the shutdown() call from ~Enclave: the Enclave is never destroyed on the normal exit path, so it was not a real safety net.
  • The existing shutdown unit test now ends with job_board.shutdown() in the production order and re-asserts the ledger is intact. A new test does the two calls in the wrong order and asserts the loss, so the ordering comments cannot go stale unnoticed if the task framework's abandonment semantics change.

Validation: ledger_test (28 cases) passes; recovery_test e2e passes, which stops the network with SIGTERM (the join -> drain -> enclave_shutdown_tasks path) and then recovers and verifies every previously committed transaction from the on-disk ledger.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One substantive concern, inline on LedgerSubsystem::append: moving ledger mutations from the ringbuffer onto the ordered lane removes the only bound on enclave-to-host ledger bytes in flight, so a disk that cannot keep up now turns into unbounded host memory growth instead of throttling the node. Details, a reproduction and a suggested fix are in the comment.

Otherwise this looks good to me. Read the full diff, built the affected targets and ran ledger_test, node_connections_test, raft_test, raft_enclave_test, historical_queries_test, indexing_test and scripts/ci-checks.sh locally at f2985f2; all green.

Custom instructions used: .github/copilot-instructions.md, .github/instructions/reviewing.instructions.md, .github/skills/testing/SKILL.md, .github/skills/formatting-and-linting/SKILL.md.

Comment thread src/host/ledger_subsystem.h
Track owned append bytes through completion or cancellation and signal overload at memory.circuit_size. Expose the combined ledger and uncommitted-tail decision as should_apply_backpressure(), independent of node role. Ordinary reads and writes use the existing 503 gate; node endpoints and in-flight replication remain exempt.

Cover threshold transitions, cancellation, concurrent submissions, single-node commit, and role-independent request shedding. Documentation and configuration descriptions remain deferred for review before publishing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Treat authenticated AppendEntries as lost before Raft term and log processing when the local ledger is overloaded. Include heartbeats for simplicity: do not generate overload NACKs or reset election timers. Normal periodic probes and existing retry handling recover missing entries once IO catches up.

Use ledger backlog rather than the broader RPC backpressure predicate so uncommitted-count pressure cannot prevent commit propagation. Cover overload-specific drops, resumption without new writes, and unaffected vote and acknowledgement processing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe request shedding and replication drops at the existing thresholds, the node-endpoint exemption, and the limits of admission control. Correct configuration descriptions without adding settings or release metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve main's extracted service-open hook and use the typed ledger service inside it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the atomic ledger-backlog fast path outside the consensus lock, and preserve the lock-free path when uncommitted-count admission is disabled. Explain immediate append ownership release and document the volume-only bound and retransmission cost.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@achamayou Amaury Chamayou (achamayou) left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One thing still missing: a CHANGELOG entry. This is user-visible in three ways: nodes now return 503 on ledger write backlog, the max_uncommitted_tx_count check now applies on backups (including to reads), and 0 no longer disables all backpressure. CHANGELOG.md is not in the diff so I cannot attach a suggestion to it; here is a proposed entry. It goes in the existing [7.0.19] section (unpublished, 7.0.18 is the latest release), as a new ### Changed heading above the existing ### Fixed. release-notes-checks.sh, prettier-checks.sh and ascii-checks.sh pass with it in place.

### Changed

- Ledger writes and reads of uncommitted ledger entries no longer travel over the host-enclave ringbuffer. The number of ledger entries the host has accepted but not yet written to disk was previously bounded by the ringbuffer filling up and stalling the enclave. It is now bounded by a backpressure threshold instead: a node returns `503` `TooManyPendingTransactions` for application and governance requests while the bytes of pending ledger appends are at or above `memory.circuit_size` (16MB by default), and drops incoming `AppendEntries` replication messages, including heartbeats, until the backlog clears. `/node` endpoints are exempt. Threshold crossings are logged. See the Backpressure section of the Resource Usage operations documentation (#8405).
- The `503` `TooManyPendingTransactions` check for `consensus.max_uncommitted_tx_count` now applies on backups as well as the primary, so a backup whose uncommitted transaction count reaches the limit rejects requests, including reads, rather than serving or forwarding them. Setting `consensus.max_uncommitted_tx_count` to `0` disables only this count-based check, not the ledger write backpressure above (#8405).
Custom instructions used
  • .github/copilot-instructions.md
  • .github/instructions/changelog.instructions.md
  • .github/instructions/reviewing.instructions.md
  • .github/skills/testing/SKILL.md
  • .github/skills/formatting-and-linting/SKILL.md

The Debug clang-tidy CI build fails on modernize-use-nodiscard for the
const getters introduced with ledger write backpressure:
AbstractLedgerWriter::is_backlogged, LedgerEnclave::is_backlogged,
LedgerSubsystem::is_backlogged and LedgerSubsystem::get_pending_write_bytes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Nodes now return 503 TooManyPendingTransactions on ledger write backlog
and drop AppendEntries until it clears, and the max_uncommitted_tx_count
check now applies on backups, including to reads. Both are user-visible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@achamayou
Amaury Chamayou (achamayou) merged commit 41d6c20 into main Oct 6, 2026
14 checks passed
@achamayou
Amaury Chamayou (achamayou) deleted the agents/ledger-ringbuffer-removal-implementation branch October 6, 2026 21:09
Amaury Chamayou (achamayou) added a commit that referenced this pull request Oct 7, 2026
Merge artefact: this branch forked from an intermediate state of #8405
that still had this call, which #8405 then removed in f2985f2 before
merging. The host owns the ledger lane drain in run_enclave_threads.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bench-ab run-long-test Run Long Test job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants