Skip to content

Expose recent committed ledger prefixes - #8214

Open
Amaury Chamayou (achamayou) wants to merge 16 commits into
mainfrom
achamayou-document-committed-ledger-chunks
Open

Amaury Chamayou (achamayou) wants to merge 16 commits into
mainfrom
achamayou-document-committed-ledger-chunks

Conversation

@achamayou

@achamayou Amaury Chamayou (achamayou) commented Aug 26, 2026

Copy link
Copy Markdown
Member

Summary

  • Add opt-in include_committed_prefix=true discovery for committed ledger entries not yet available in canonical .committed files.
  • Serve immutable .committed_prefix resources with 307 Temporary Redirect, Cache-Control: no-store, range requests, ETags, and representation digests.
  • Preserve physical ledger boundaries and keep prefix files excluded from committed-only discovery and recovery.
  • Materialise prefixes without stalling the ledger writer: the chunk header and positions table are built under the ledger state lock, then the entry bytes are read with pread() on a duplicated descriptor with no ledger lock held. pread() never touches the writer's FILE* position, and the range is stable because Ledger::truncate never regresses past committed_idx. Committed bytes are always flushed (committable entries and completed files fflush), so a violation surfaces as a short read (HTTP 500), never as incorrect bytes.
  • Ignore .committed_prefix files in ccf.ledger.Ledger directory discovery, matching the host; they remain readable individually with ccf.ledger.LedgerChunk.
  • Add host and Python tests, endpoint e2e coverage, a 64 KiB-50 MiB benchmark matrix, OpenAPI updates, and operator documentation.

Design discussion: #8207

Testing

  • ledger_test (including a case with non-committable entries inside the served range and an unflushed tail after it) and files_cleanup_test
  • Python SDK test suite, including Ledger ignoring .committed_prefix files
  • ledger_bench: pread() materialisation runs at 0.59-1.08x of a plain read of the same file across 64 KiB-50 MiB
  • schema_test committed-prefix-download scenario, pinned with a high snapshot_tx_interval so a due snapshot cannot promote the source file to .committed mid-test
  • Node OpenAPI validation
  • Full logging sample build

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>

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.

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.

Comment thread src/node/rpc/file_serving_handlers.h Outdated
Comment thread tests/schema.py
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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
@achamayou
Amaury Chamayou (achamayou) marked this pull request as ready for review September 21, 2026 09:58
@achamayou Amaury Chamayou (achamayou) added the run-long-test Run Long Test job label Sep 21, 2026

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`.

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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

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.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread src/host/ledger.h
}

const auto positions_size = entry_count * sizeof(positions.at(0));
std::vector<uint8_t> chunk(positions_offset + positions_size);

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.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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(

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.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-long-test Run Long Test job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants