Expose recent committed ledger prefixes - #8214
Amaury Chamayou (achamayou) wants to merge 16 commits into
Conversation
Add opt-in discovery and immutable download endpoints for recent committed ledger entries that have not yet been published as canonical .committed files. Include host and SDK support, unit and end-to-end coverage, benchmarks, OpenAPI, and operator documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add the release note for draft PR #8214. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds opt-in access to recent committed ledger prefixes while preserving canonical ledger and recovery behavior.
Changes:
- Adds prefix discovery, immutable downloads, ranges, digests, and metadata.
- Extends host and Python ledger parsing.
- Adds tests, benchmarks, OpenAPI definitions, and operator documentation.
Custom instructions used:
.github/copilot-instructions.md.github/instructions/changelog.instructions.md.github/instructions/reviewing.instructions.md
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
CHANGELOG.md |
Records the new capability. |
include/ccf/http_consts.h |
Adds the chunk-kind header. |
src/host/ledger.h |
Materializes committed prefixes. |
src/host/ledger_filenames.h |
Recognizes and excludes prefix files. |
src/host/test/ledger.cpp |
Tests prefix creation and recovery exclusion. |
src/host/test/ledger_bench.cpp |
Benchmarks prefix materialization. |
src/host/test/files_cleanup_test.cpp |
Tests strict prefix filenames. |
src/node/rpc/file_serving_handlers.h |
Implements discovery and download endpoints. |
src/node/rpc/ledger_interface.h |
Extends the ledger subsystem interface. |
src/node/rpc/ledger_subsystem.h |
Connects RPC and host ledger operations. |
src/node/rpc/node_frontend.h |
Bumps the API version. |
python/src/ccf/ledger.py |
Parses committed-prefix filenames. |
python/tests/test_ledger.py |
Tests Python filename handling. |
tests/e2e_operations.py |
Adds endpoint integration coverage. |
tests/schema.py |
Registers the new e2e scenario. |
doc/operations/configuration.rst |
Documents operator feature gating. |
doc/operations/ledger_snapshot.rst |
Documents prefix semantics and usage. |
doc/schemas/node_openapi.json |
Updates the generated API schema. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Reject zero-length suffix ranges, cover opt-in redirects across lagging and late-joining nodes, exclude valid committed-prefix files from read-only ledger lookup, and fix the clang-tidy callable warning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…mmitted-ledger-chunks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mmitted-ledger-chunks
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ccf::pal::Mutex -> ccf::ds::Mutex, TimeBoundLogger -> ccf::ds::TimeBoundLogger, asynchost ledger filename helpers -> ccf::ledger, and the subsystem type lookup uses the abstract interface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n Ledger - Materialise the chunk header and positions table under the ledger state lock, then read the entry bytes with pread() on a duplicated descriptor without holding any ledger lock, so serving a prefix does not stall the ledger writer. - Ignore .committed_prefix files in ccf.ledger.Ledger directory discovery, matching the host. - Pin snapshot_tx_interval in the committed-prefix e2e scenario so a due snapshot cannot promote the source file to .committed mid-test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
|
||
| The ``.committed_prefix`` suffix distinguishes this synthetic resource from a canonical physical ledger file. The response contains a normal completed ledger representation - header, unchanged transaction bytes, and positions table - but only for the selected committed range. It can be read directly with :py:class:`ccf.ledger.LedgerChunk`. | ||
|
|
||
| Both the temporary redirect and the committed-prefix response include ``Cache-Control: no-store``. The response also includes ``x-ms-ccf-ledger-chunk-kind: committed-prefix``. Clients must not archive, install, or use this resource for recovery as though it were a canonical ``.committed`` file. In particular, committed-prefix files are ignored by ledger directory discovery, both by nodes and by :py:class:`ccf.ledger.Ledger`, and can only be read individually with :py:class:`ccf.ledger.LedgerChunk`. |
There was a problem hiding this comment.
What's the expected pattern that operators should follow to keep canonical backup progress separate from prefix progress?
With the current API, an external synchronization process advances since to the downloaded chunk’s end plus one. By passing this new prefix parameter, after downloading a prefix covering 101–140, it would ask for 141. If the physical chunk later becomes ledger_101-140.committed, for example through a forced ledger chunk before 141, that cursor cannot discover it anymore.
Since prefixes cannot replace canonical files for recovery, would operators need an independent canonical reconciliation pass that removes prefix files older than the latest committed file? An example covering this specific transition phase would be helpful for clarifying this part.
There was a problem hiding this comment.
The committed prefix files deliberately have a distinct suffix to differentiate them, and allow the nodes to ignore them on DR if they somehow get written to a ledger directory.
It's not clear to me that the synchroniser is necessarily the job that should be downloading these files, but if it is, it must not update its persistency watermark when it does. If since=101 and the return value is a 101_138.committed_prefix, then the value of since must remain 101, and the next download attempt from since=101 may yield a final 101_140.committed.
The goal of this API is not to be used to allow the synchroniser to back up files more aggressively (the disk size benefit would be negligible), it is to allow exposing a complete ledger to auditors on a relatively slow-moving ledger, where the current chunk stays open for longer than it would take for the auditor to fetch the files. Incomplete chunks ideally get downloaded to a separate directory, and are treated as ephemeral.
|
|
||
| Both the temporary redirect and the committed-prefix response include ``Cache-Control: no-store``. The response also includes ``x-ms-ccf-ledger-chunk-kind: committed-prefix``. Clients must not archive, install, or use this resource for recovery as though it were a canonical ``.committed`` file. In particular, committed-prefix files are ignored by ledger directory discovery, both by nodes and by :py:class:`ccf.ledger.Ledger`, and can only be read individually with :py:class:`ccf.ledger.LedgerChunk`. | ||
|
|
||
| The ``307`` redirect intentionally differs from the ``308 Permanent Redirect`` used for canonical ``.committed`` files. A physical chunk's name and range are final, while the committed-prefix range selected for the same ``since`` value may grow as the commit watermark advances. The exact ``.committed_prefix`` URL is immutable once returned, so it remains suitable for retrying or resuming that specific download. |
There was a problem hiding this comment.
Could we clarify the scope of “immutable once returned,” especially across disaster recovery?
For example, let's say we persist a prefix covering 101–140 while the latest available canonical backup ends at 100. If recovery resumes from that older backup, the saved prefix and indexed rows are no longer necessarily part of the recovered history.
What's a good way for consumers to detect that a prefix file is no longer part of the recovered history and how can they reconcile their saved prefixes?
There was a problem hiding this comment.
Ideally, the operator makes sure that this does not happen. If it does though, receipts are still the way to assign blame: https://ccf.dev/main/audit/receipts.html#denounce-an-invalid-recovery.
If desired though, it's possible to scan through the chunk and find the view change and/or service identity update transaction.
| } | ||
|
|
||
| const auto positions_size = entry_count * sizeof(positions.at(0)); | ||
| std::vector<uint8_t> chunk(positions_offset + positions_size); |
There was a problem hiding this comment.
Does this allocate and fill the whole response buffer with zeros while holding the ledger state lock? If ledger writes and commits use the same lock, large downloads could potentially delay writes, right?
I'm wondering if this could be a problem, especially for large chunks (we use 100 MB chunks). Could we move the large allocation outside the lock?
There was a problem hiding this comment.
So that files_lock I think is no longer necessary, and the locks in this file don't use the clang scoping macros we have elsewhere and that are quite good for safety, so I will make a small detour and tidy this up before coming back to this. In short though, yes we could allocate outside I think, although the impact is probably not very high.
| return; | ||
| } | ||
|
|
||
| auto contents = read_ledger_subsystem->read_committed_ledger_prefix( |
There was a problem hiding this comment.
A request for just eight bytes, such as Range: bytes=0-7, still allocates and reads the entire prefix before returning those bytes, even when no full digest is requested. Invalid ranges also do this work before being rejected.
Could we validate the range first and avoid reading the whole prefix when possible? Otherwise, downloading a large prefix in small pieces repeatedly makes the node read the same data.
Summary
include_committed_prefix=truediscovery for committed ledger entries not yet available in canonical.committedfiles..committed_prefixresources with307 Temporary Redirect,Cache-Control: no-store, range requests, ETags, and representation digests.pread()on a duplicated descriptor with no ledger lock held.pread()never touches the writer'sFILE*position, and the range is stable becauseLedger::truncatenever regresses pastcommitted_idx. Committed bytes are always flushed (committable entries and completed filesfflush), so a violation surfaces as a short read (HTTP 500), never as incorrect bytes..committed_prefixfiles inccf.ledger.Ledgerdirectory discovery, matching the host; they remain readable individually withccf.ledger.LedgerChunk.Design discussion: #8207
Testing
ledger_test(including a case with non-committable entries inside the served range and an unflushed tail after it) andfiles_cleanup_testLedgerignoring.committed_prefixfilesledger_bench:pread()materialisation runs at 0.59-1.08x of a plain read of the same file across 64 KiB-50 MiBschema_testcommitted-prefix-downloadscenario, pinned with a highsnapshot_tx_intervalso a due snapshot cannot promote the source file to.committedmid-testloggingsample build