Status: Current
The decisions each component raises, with the measurements behind them. Superseded in part by the 1.3 ship (2026-10-01): see the newer pages.
Module-level decisions¶
2026-09-23. Every component in COMPONENTS.md is built and tested: 203 tests pass, 0 fail (at the time; 324 at e3d7b46, CI green)
(83 module and engine tests, and a 120-cell integration matrix). The measurements below are from
ModuleMeasurementsTest and IntegrationMatrixTest, with raw data in eval/results/modules/. Host:
14-core Apple silicon, JDK 23, single machine. Every rate is a ceiling for this machine, not a
deployment figure, and macOS fsync is about 8× costlier than Linux node-local fsync
(eval/results/fsync_cost.json).
Each decision is labelled D-Cn-k after its component. Where I have a recommendation it is marked Rec, and accepting it is a valid answer.
Status of every decision¶
Every decision below now runs on its recommended default. These are adopted defaults, not owner approvals: each one is reversible, and each one stays open until the owner confirms or overrides it.
| Decision | Running default | Where it lives | Reversible by |
|---|---|---|---|
| D-C1-1 default chunker | fmt:dicom@1,nifti@1,tiff@1+cdc:1048576:262144:4194304 (reviewed format adapters around 1 MiB CDC; plain 1 MiB CDC earlier on 2026-09-26, 64 KiB before) |
ChunkerSpec.DEFAULT_CDC, config core_default_chunker |
per-domain chunker at creation; config key for new domains (existing domains keep the spec they recorded) |
| D-C1-2 16 KiB CDC per collection | offered, not default | any cdc: spec is accepted per domain |
policy |
| D-C1-3 reject 256 KiB CDC | not a default; still accepted if asked | policy | policy |
| D-C2-1 default hash | SHA-384 (CNSA) | BlockHash.DEFAULT |
per-domain hash at creation |
| D-C5-1 keyed ids | HMAC-SHA-384, 96 hex | BlockCodec |
new domains only (ids are permanent) |
| D-C6-1 reference storage at scale | counted compact table (primitive arrays, ~75 B/block) + per-collection run holds; incremental multiset metaHash; copy-on-write snapshot cut | BlockTable / CompactBlockTable, RefIndex, RefHolds |
another BlockTable (on-disk / LSM, STORAGE-DIRECTION D3); owner may override |
| D-C8-1 site weighting | log10(measured write rate) |
ReplicaPlacer |
code constant |
| D-C10-1 trim | surplus kept through a grace period, never below R | StorageEngine.trim |
grace_ms per call |
| D-C10-2 sync at seal | one barrier per write per site | FsBinding.seal |
built |
| D-C10-3 batched streaming reads | window of at most 256 blocks and 64 MiB, one window prefetched; 32 blocks per site request | StorageEngine.readWindow, readWindowBytes, fetchSlice |
fields |
| D-C11-1 minimum replication | R ≥ 2 for durable collections (OUT-41); one copy only for a cache in the scratch or cache retention class, at creation, profile creation and class change (N-DC11, owner 2026-10-02); R=3 used everywhere tested |
PolicyEngine, Profile.of, LifecycleEngine.assignClass |
decided (N-DC11); the EC/replica/single modes are designed in DURABILITY-MODES.md |
| D-C11-2 supported configurations | all tested combinations (180 cells at 64 KiB + 40 at 1 MiB, incl. remote) | IntegrationMatrixTest |
— |
Summary of verdicts¶
| Component | Tests | Verdict |
|---|---|---|
| C1 Chunker | 18 | Correct at every size; content-defined chunking behaves as measured on edits |
| C2 Block hash | part of 6 | Known answers pass; SHA-384 is 1.67× slower than SHA-256 here |
| C3 Tenancy and policy | 7 (40+ matrix cells) | Every rule enforced; every refusal names its rule |
| C4 Keys | part of 6 | Derivations separated by tenant, domain and purpose; destroying a root destroys its keys |
| C5 Block codec | 10 | Shared modes converge, keyed domains separate, tampering refused, oracle rule holds |
| C6 References | 5 | Idempotent; agrees with a reference model across 50,000 random operations |
| C7 Versioning | 5 | Shadowing, tombstones, compare-and-set, stable ids and citations |
| C8 Placement | 7 | Constraints enforced, unsatisfiability explained, deterministic and weighted |
| C9 Bindings | 9 | Contract holds for RAM and filesystem. Found and fixed: MemBinding returned FAILED instead of REFUSED for a missing extent |
| C10 Engine | 16 | Every owner case and failure passes (below) |
| C11 Integration | 120 | Every option combination passes the full lifecycle |
C10 behaviours verified end to end: - Composing public datasets stores zero blocks. - A second tenant publishing the same public bytes stores zero blocks. - Augmenting another tenant's 2 MB file with a 500-byte insert stores 1–4 blocks. - A revoked grant blocks reads immediately. - Sealed tenants are refused references and grants in both directions, and copy public data in. - Withdrawal honours PIN, COPY and BREAK, and reclaims exactly what nothing else references. - R−1 site losses leave every version readable, and repair restores R copies. - A corrupt replica is read around, and scrub repairs it. - Durability fails closed on unattested RAM. - A refused commit leaves only reclaimable garbage. - Keyed domains never record a raw content hash.
C1: chunker¶
32 MiB base, five edit workloads. Figures are blocks retained / new bytes that must be stored:
| Chunker | MB/s | Append 1 % | Insert 0.1 % | Prepend 0.1 % | Overwrite 20×4 KiB | 10 small inserts |
|---|---|---|---|---|---|---|
fixed:65536 |
6,059 | 98.8 % / 328 K | 49.9 % / 16.0 M | 0.0 % / 32.0 M | 96.1 % / 1.3 M | 9.0 % / 29.1 M |
fixed:16384 |
8,819 | 99.0 % / 328 K | 49.9 % / 16.0 M | 0.0 % / 32.0 M | 99.0 % / 336 K | 9.1 % / 29.1 M |
cdc:65536:8192:131072 |
878 | 98.7 % / 442 K | 99.6 % / 161 K | 99.6 % / 51 K | 96.0 % / 1.9 M | 98.1 % / 606 K |
cdc:16384:4096:65536 |
505 | 99.0 % / 355 K | 99.7 % / 70 K | 99.9 % / 46 K | 98.5 % / 729 K | 99.4 % / 279 K |
cdc:262144:32768:1048576 |
870 | 97.4 % / 570 K | 99.1 % / 423 K | 98.2 % / 229 K | 87.4 % / 6.2 M | 90.1 % / 4.4 M |
keyed-cdc:65536:… |
869 | 98.9 % / 393 K | 99.8 % / 117 K | 99.6 % / 146 K | 96.1 % / 1.9 M | 98.1 % / 811 K |
In the integration matrix's version-2 workload, content-defined chunking reused 73 % of blocks against 54 % for fixed blocks.
- D-C1-1: default chunker. Superseded 2026-09-26 by 1 MiB CDC; see the update at the end.
The original recommendation was
cdc:65536:8192:131072. After a single insertion, fixed blocks have to store 100× more new data than content-defined ones, and after several small insertions about 50× more. It runs at 878 MB/s on one thread, which is 2.2× an LTO-10 drive. - D-C1-2: offer 16 KiB content-defined chunking per collection for text-heavy collections with frequent small edits. It stores 2–3× less on edits, but produces 3.1× more blocks, so more index and reference entries, and runs at 505 MB/s. Rec: allowed as a per-domain option, not the default.
- D-C1-3: reject 256 KiB content-defined chunking. Scattered overwrites store 3.3× more than at 64 KiB. Rec: don't offer it.
- Keying the chunker costs nothing measurable (869 against 878 MB/s). The sealed-tenant default stands.
C2: block hash¶
| Hash | MB/s, one thread | Id bytes |
|---|---|---|
| SHA-256 | 3,008 | 32 |
| SHA-384 | 1,805 | 48 |
The hash choice has no effect on deduplication (reuse was 51.6 % with either across all 120 cells).
- D-C2-1: default hash. Rec: keep SHA-384 (CNSA). It is 1.67× slower, but still 4.5× an LTO-10 drive on one thread. Re-measure on the deployment host's CPU: this laptop has SHA-256 hardware acceleration, and the gap on an x86 server may be different.
C5: codec¶
| Mode | Seal MB/s | Open and verify MB/s |
|---|---|---|
| NONE | 1,808 | — |
| COLLECTION | 1,180 | 1,131 |
| GROUP | 1,071 | 1,060 |
| GLOBAL | 1,179 | 1,229 |
Shared modes cost about 35 % more than NONE because they hash every block. That is the price of deduplication and is well above media rates.
- D-C5-1: keyed block ids are always HMAC-SHA-384, 48 bytes (96 hex characters), even in a SHA-256 domain; GLOBAL ids are the domain's own hash. Rec: keep it. Ids stay uniform across keyed domains, and the keyed part is CNSA regardless of the content hash chosen. The alternative is to follow the domain's hash, which saves 16 bytes per id in SHA-256 domains.
C6: reference index¶
- D-C6-1: how references are stored at scale. Today each block holds a set of holder names,
so every operation is idempotent and the index agrees with a reference model across 50,000 random
operations. That design is correct but can't be the representation at 10⁹ blocks: a string per
reference, in memory. Rec: keep the set semantics, store them as a counted table plus an
append-only holder log in the federation index, and design this before the index integration
stage. This is
TENANCY-AND-DEDUP.md§12 item 3, now blocking the next stage. Adopted default (builder, 2026-09-26; owner may override): a counted table behind theBlockTableinterface. Per block: id bytes, domain ordinal, length, reference count, required copies, up to four site ordinals and flags, in paged primitive arrays (no per-block object or string). Holders are counts; which run positions are counted is kept per collection and run (RefHolds), with the positions derived from the run in the version log, so adoption and drops stay idempotent. This also closes a data-loss case of holder names, which carried no collection: two collections holding the same run shared its references, and withdrawing one reclaimed the other's blocks.metaHashis an incremental multiset hash, O(1) while the policy is unchanged (the policy digest is cached against the policy's and key ring's generations). A snapshot cuts the table copy-on-write, opens with asnaphead(format, delta count) and ends with asnapcheck; install refuses anything torn, padded, spliced or downgraded, and a pre-D-C6-1 snapshot installs only through the explicitinstallLegacy. The index cuts the core inside its consistent point and writes it after, and installs a snapshot only once the rebuilt core has verified. A snapshot is authenticated (HMAC-SHA-384 under a key derived from the master key, checked before anything is applied) and bound to the index position it was cut at; its holds are checked against the version log it carries (every live collection holds exactly the runs it adopted, versions name only adopted runs, a position is uncounted only when its block is gone or a withdrawal dropped it), and its counts against its holds. A version and its references, and a withdrawal's drop and its mark, reach the journal in one batch; a trimmed copy's release carries an intent, so a crash before the copy leaves its site is recovered, not leaked. Measurements:eval/results/scale/.
C8: placement¶
- D-C8-1: how much faster sites are favoured. Placement weight is
log10(measured write rate). With a 375× speed difference, the test requires the fast site to take between 52 % and 90 % of single-copy placements, and it passes. The exact share wasn't recorded. An unmeasured site never outranks a measured one (tested). Rec: keep the logarithmic weight; a linear weight would put almost everything on the fastest site and concentrate failure risk.
C10: engine findings¶
The engine is correct in every tested behaviour, and the measurements show where it is slow. Publish and verified read of one 32 MiB file across 4 sites:
| Locus | Replication | Publish MB/s | Read MB/s |
|---|---|---|---|
| RAM | 1–3 | 111–236 | 74–174 |
| Filesystem | 1 | 4.8–6.0 | 31–34 |
| Filesystem | 3 | 1.6–2.0 | 30–36 |
- F-C10-1: extra copies are never trimmed. When a site is down, repair re-replicates elsewhere to get back to R copies; when the site returns, the block holds more than R copies and nothing removes the surplus. Measured: at R=2 an average of 9.9 blocks per cell were over-replicated after sites returned. D-C10-1: trim policy. Rec: keep the surplus through a grace period (a returning site may fail again), then trim back to R, never below, preferring to keep copies in distinct failure domains with the best measured health.
- F-C10-2: filesystem publish is slow because it syncs to disk per block, serially.
FsBindingfsyncs on everyappend, and the engine writes to each site in turn. On macOS that is about 6 ms per 64 KiB block per copy. D-C10-2. Rec: move the sync toseal()(one barrier per write batch), which is what the interface's two-state write exists for, and write to sites in parallel. I expect this to improve filesystem publish by more than 10×; I'll measure after the change. - F-C10-3: reads go block by block through a staging file. Each block is enqueued, written to a staging file, read back and deleted, and completion is polled. D-C10-3. Rec: request every block of a file from a site in one call, and add a streaming read path to the interface, as the "stream" requirement calls for.
- F-C10-4: the engine is single-threaded (its public methods are synchronized). That is correct and simple, and concurrency comes with the index integration stage. It is recorded, not proposed for now.
C11: which configurations are supported¶
All 120 cells pass: every mode × {fixed, content-defined, keyed content-defined (keyed modes)} × {SHA-256, SHA-384} × R ∈ {1, 2, 3} × {RAM, filesystem}.
| Replication | Read with one corrupt copy | Read after scrub | Fewest copies after scrub |
|---|---|---|---|
| R=1 | 0 / 40, refused, never wrong | 0 / 40 | 0 for the corrupted block |
| R=2 | 40 / 40 | 40 / 40 | 2 |
| R=3 | 40 / 40 | 40 / 40 | 3 |
With R=1, corruption is detected and refused, never returned as data. Nothing is left to repair from.
- D-C11-1: the minimum replication policy. Rec: R ≥ 2 for any collection that counts as durable, with R=1 permitted only for explicitly declared caches or recomputable derived data. The design of record is R=3.
- D-C11-2: supported configurations. Rec: declare all 120 tested combinations supported, with the defaults: content-defined 64 KiB, SHA-384, R=3. Keyed content-defined chunking is the default in sealed tenants.
What comes next, once these are decided¶
- Engine performance: D-C10-2 (sync at seal, parallel site writes) and D-C10-3 (batched and streaming reads), re-measured.
- Over-replication trim (D-C10-1).
- Reference storage at scale (D-C6-1), then wiring the engine into the federation index for quorum commit across hosts. That is the next integration stage.
- Metadata-key IV discipline (S1), before any media is written.
Update 2026-09-23 (afternoon): findings fixed, smoke test, benchmark, CI¶
The recommended defaults for D-C10-1, D-C10-2 and D-C10-3 were adopted and built. They stay configurable. 208 tests, 0 failures, on this machine and on CI.
| Finding | Fix | Before | After (same Mac) |
|---|---|---|---|
| F-C10-2: one fsync per block, sites written serially | BlockStore.stage / commitStaged: syncs run in parallel at seal, one directory sync per batch, sites sealed in parallel |
disk publish at R=3: 1.6–2.0 MB/s | 46–78 MB/s |
| F-C10-3: reads staged one block at a time | readTo(OutputStream): windowed, one request per site per window, parallel across sites, authenticated per block, per-block fallback |
verified disk read: 30–36 MB/s | 230–380 MB/s |
| New (found by the benchmark): repair one block at a time | Plan first, one fetch per source site, one sealed write per target site, all in parallel | 72 copies/s | ~1,400–1,600 copies/s |
| F-C10-1: surplus copies never trimmed | trim(graceMs): after the grace period, down to R and never below, distinct failure domains first |
never | tested; idempotent |
| New: a long domain id made every block unwritable (stores cap ids at 128 characters) | Block names hash long or unusual domain ids | — | tested |
Versioning with deduplication (benchmark). Ten successive versions of a file, each with a 0.1 % insertion: the deduplicating modes stored 0.01 of the logical bytes published, NONE stored 3.0 (R=3).
Decision D-C2-1, answered by an x86 host¶
The CI runner (Azure, Linux x86-64, 2 cores) measured SHA-256 at 1,365 MB/s and SHA-384 at 613 MB/s, a 2.2× gap against 1.67× on Apple silicon. SHA-384 is still above one LTO-10 drive (400 MB/s) on a single core, with less headroom than the laptop suggested. Hashing parallelises per block, so a many-core server has room to spare. The recommendation to keep SHA-384 stands, now on x86 evidence. The HPC cluster run (job 221777) adds a server-class x86 figure.
A measurement correction: JIT warm-up¶
The benchmark's first codec figure (269 MB/s) was a measurement artifact, not a regression. The codec seals at 103 MB/s cold and reaches ~1,100 MB/s after ~3,000 seals (~200 MB) in the same JVM, and JDK 21 and 23 are identical once warm. The benchmark now warms up the primitives and the whole engine path before measuring, and reports steady state (1,043 MB/s), because a storage server runs warm. The first ~200 MB after a process starts are slower, and that should be expected after every restart.
Tools¶
eval/gfs-core.sh smoke [dir] [fs|mem] [--attest]runs 25 checks end to end on real sites and exits 0 or 1. It passes 25/25 on disk (3.4 s) and in RAM (0.5 s), and runs in every build.eval/gfs-core.sh bench [...]measures primitives, publish and read, versioning, repair and scrub, and writes JSON toeval/results/bench/.- Both are classes in the bundle (
io.cresco.gfs.core.tools), so they run on any deployment host. - CI (then
.github/workflows/test.yml; on the HPC cluster since 2026-10-02, docs/CI.md) runs the suite, the wire-contract lint, the smoke test and a small benchmark on every push, and uploads the results. The first run passed.
Update 2026-09-23 (evening): on HPC servers¶
The smoke test and benchmark ran on HPC compute nodes via SLURM: job 221777 on cluster node 03, then job
221810 on cluster node 01 after the fixes below. Logs and JSON are in eval/results/bench/cluster/. Both are
x86-64 with 32 cores, JDK 21.
Two defects the server found, both fixed:
- The durability probe was a single timing pass. On cluster node 03 it classified one of four identical
node-local directories INDETERMINATE and the other three COSTS_TIME: the first site probed ran on
a cold JVM. The probe now warms up, runs five rounds alternating which pass goes first, and counts
a locus durable only when every round agrees (
classifyRounds). The per-round ratios are kept for audit. On cluster node 01 all four node-local sites came back unanimous, and the smoke test passed 25/25. - The engine was CPU-bound on one thread. On x86 each core is slower at this work (SHA-384 718 MB/s, codec ~545 MB/s), and every block was hashed and encrypted serially. Per-block work now runs across cores, with the decisions that touch shared state kept sequential and in order. Domain keys are memoised, and the memo is cleared when a root is installed or destroyed.
Correction: with the unanimous probe, the HPC cluster shared project filesystem measured COSTS_TIME (13–14 MiB/s), contradicting the one-pass ×0.94 from 2026-09-19. It is admitted; its real cost is speed, 10–18× slower than node-local storage.
cluster node 01, node-local disk (job 221810):
| Mode | R | Publish MB/s | Verified read MB/s | Stored / logical (10 versions) | Repair copies/s |
|---|---|---|---|---|---|
| NONE | 1 | 175 | 361 | 1.0 | — |
| NONE | 3 | 103 | 420 | 3.0 | 2,397 |
| COLLECTION | 1 | 140 | 380 | 0.00 | — |
| COLLECTION | 3 | 74 | 367 | 0.00 | 2,234 |
| GROUP | 3 | 76 | 380 | 0.00 | 2,464 |
| GLOBAL | 3 | 77 | 380 | 0.00 | 2,336 |
Against cluster node 03 before the CPU fix, deduplicating-mode publish went from ~100 to 131–140 MB/s at R=1, and verified reads from 226–241 to 367–383 MB/s. At R=3 the limit is now the disk syncs (74–77 MB/s). In RAM on the same CPU, publish reaches 152–227 MB/s at R=1 and reads 396–472 MB/s.
D-C2-1 on a server: SHA-384 718 MB/s against SHA-256 1,765 MB/s per core, a 2.5× gap on x86 (1.67× on Apple silicon, 2.2× on the CI runner). Per core, SHA-384 is 1.8× one LTO-10 drive, and hashing now runs across cores. The recommendation to keep SHA-384 stands, now on server-class evidence, but it costs 2.5× on x86.
What limits a single stream now: content-defined chunking runs single-threaded per file at ~600 MB/s on x86. That is the next ceiling above one LTO-10 drive (400 MB/s). Several files publish in parallel; one very large file does not yet.
Update 2026-09-23 (night): the core on the fabric¶
The three outstanding items are closed or explicitly handed back.
1. The engine runs across hosts, with agreement. The storage core now runs inside the federation
index (coresvc/CoreService). The index is its journal and its replication.
- One apply path. Every persistent change is a
Deltaapplied throughStorageEngine.apply. That one function runs live, on replay after a restart, and on every replica, so the three cannot diverge. - Journaling. Each operation's changes go out as one
core.batchthrough the index's commit path. The batch is forced to disk before the operation returns, then shipped to the replicas.statehashnow carriescore_hash; snapshots carry the core too. - Storage nodes. The index reaches them through
RemoteBinding: the unchangedExtentBindingcontract over Cresco RPC. Each node serves its ownFsBindingthroughExtentServer, and only to the index that owns it. - Crash safety. Before any seal, the blocks and sites a write is about to create are journaled as an intent. Recovery reclaims every copy an intent names that the index never recorded.
- Tenant roots. They leave memory only wrapped under
core_master_key. Without that key the core does not start.
Evidence:
- JUnit 294/294. JournalReplayTest 12/12: replay and snapshot give an identical hash; a crash
between seal and commit leaves no orphans; a refused seal cleans up; no root in the clear. The new
REMOTE locus runs the binding contract and all 60 extra integration-matrix cells over the protocol.
- Live Cresco fabric, 9 JVMs, eval/core_fabric_check.py: 61/61
(eval/results/core_fabric_20260923-180417.json). Covered:
- all four dedup modes, and v2 of a 3.5 MB file stores 234 KB at R=3;
- cross-tenant GLOBAL dedup stores 0 blocks, and compose stores 0 bytes;
- a grant-derived publish stores only the addition;
- a sealed tenant is refused by rule;
- the replica's hash equals the primary's;
- no plaintext on any disk;
- a storage node killed: every read survives, repair and trim work;
- the index killed -9: the replayed hash equals the pre-crash hash, and dedup survives the restart;
- the index halted between seal and commit: 1,128 sealed orphans are reclaimed from the journaled
intent, leaving 1,125 files on disk = 1,125 recorded copies.
Found live and fixed:
- A dead node stalled liveness. The core's one timer thread blocked on an RPC to it. Fix: liveness
sync gets its own thread, a timed-out node is quarantined at once, and control calls time out at 10 s.
- An index restart marked every node LOST. Replayed last_seen values were stale. That would
also start the prototype's repair storm. Fix: each node gets a full grace window from start.
- Control messages are capped at 1 MiB by the websocket. Bulk bytes now arrive by chunked
core.upload, and reads are ranged: core.readrange fetches only the covering blocks.
Throughput through the fabric is low, and the cause is known. One Mac running 9 JVMs, R=3: engine publish 15–18 MB/s and engine read 59–78 MB/s. Bytes travel base64-encoded inside control-plane MsgEvents. In-process on the same machine the engine does 568–604 MB/s. The fix is to move the extent data onto the Cresco dataplane (FrameBus), as the prototype's fragment path does. That is the next performance item.
2. A single large file no longer caps at one core.
- Parallel content-defined chunking is byte-identical to sequential (proven in ChunkerTest), and the
whole-file hash overlaps the rest of the work.
- MemBinding usage is O(1): describe() had summed every stored extent on every placement call.
- One 1 GiB file publishes at 568–604 MB/s (was 186).
3. Decisions. The table at the top records every decision as an adopted, reversible default, not an owner approval. Two remain open and matter at scale: D-C6-1 (reference storage) and D-C11-1 (enforce R ≥ 2 for durable collections).
Known limits, stated plainly:
- Core RPCs have no per-tenant authorization yet. Anyone holding the Cresco service key can call
core.*, and tenant-level authorization is an open question.
- Per-block metadata is journaled at roughly 130 bytes per block, about 2 MB per GiB. Journal
compaction exists only as snapshot install.
- The index keeps retained log entries in memory.
Update 2026-09-23 (late): the data path, measured leg by leg¶
No file byte travels in a control message. Clients stream over GKT, a reliable transfer on the
Cresco client dataplane (per-chunk sha256, sliding window, selective acknowledgements, retransmit).
The index and the storage nodes exchange bodies as FrameBus dataplane frames, and a lost frame
retries the idempotent exchange. The engine streams with bounded memory, runs concurrently, and
reads directly from media that can (readNow).
Measured on one Mac: 7 Cresco JVMs of 512 MB heap each, sharing one SSD, R as stated
(eval/core_throughput.py, eval/results/core_throughput_*.json).
| Leg | Result |
|---|---|
| Index ↔ node, pure transport (no disk, no crypto) | ~300 MB/s per flow at ≥4 MiB messages; 540–580 MB/s multi-flow to one node; ~550 MB/s out / 1.0–1.2 GB/s in across 5 nodes |
| Client → index upload (8 flows × 1 MiB chunks, 16 MiB in flight per flow) | 420–490 MB/s, 0 retransmits |
| Engine read on the index (no client leg), 1 MiB blocks | 256–395 MB/s on 1 flow; 590–705 MB/s on 4–8 flows |
| Engine publish, 1 MiB blocks | 368 MB/s at R=1, 126–147 MB/s at R=3 |
| Engine publish, 64 KiB blocks | 97 MB/s at R=1, 54 MB/s at R=3 |
| Client download to a local file (Python client, 8 flows) | 230–263 MB/s: the client leg, not the engine, is the limit |
Findings, all fixed and covered by tests:
- Data loss. Concurrent appends to one write dropped entries on the disk binding, so blocks behind
a committed version were never sealed.
- RAM. An in-flight window counted in chunks, not bytes, exhausted a 512 MB heap. Windows are now
a byte budget, enforced by both ends.
- Stalls. Lost non-persistent frames stalled reads for 15 s each; they now cost a retry.
- Wasted I/O. Every block read made three extra disk round trips through sink directories, and
every seal re-read what it had just staged.
- A thread leak in the engine and disk-binding pools.
- The "1 MiB cap" was the Python websockets default max_size, not the server (which allows 1 GiB)
or the broker (128 MiB). The pycrescolib dataplane now accepts 64 MiB.
Decision for the owner: D-C1-1 block size. 1 MiB content-defined blocks publish about 3× faster
than 64 KiB (368 against 97 MB/s at R=1) and cut per-block metadata 16×. The cost: an edit stores
about 1–2 MiB instead of about 64–128 KiB. For petabytes of imaging, 1 MiB is the recommended default.
It is available per domain today (chunker=cdc:1048576:262144:4194304); the global default is
unchanged pending the owner's confirmation.
Next: - Pipelined ingest (publish while parts arrive). End-to-end ingest today is upload then publish: 196 MB/s at R=1, 98–113 MB/s at R=3. - A Java client measurement of downloads. - Physical R=3 on separate hosts: three copies on one laptop SSD is not a deployment figure.
Addendum: failures found under concurrent load, all fixed (2026-09-23, night)¶
- Silent handler death. An OutOfMemoryError in a handler vanished inside the plugin's message
pool, whose processor catches only Exception.
CoreServicenow catches and logs every Throwable. The error itself came from pipelined ingest overcommitting a 512 MB index heap. The index now sets each upload's window from a shared budget (64 MB across all open flows, returned in thecore.putreply and enforced by the receiver), and per-site shipping is 2 batches in flight. - An interrupt closing a shared file. Interrupting a thread closed the
FileChannelthat every receiver shared. Readers now use their own channel. - Acks sent from the JMS consumer thread could block under producer backpressure and stall the flows that wait for them. Acks now go out on their own ordered lane.
- The hub broker. On the test fabric every agent attaches to one global controller, so every byte passes one broker JVM. Under ~1 GB/s it exhausted a 1 GB heap and every agent declared its controller lost at the same moment. A hub broker must be sized for bulk traffic in flight: the tests use 4 GB. In a deployment, regional brokers split this load.
- Pipelined ingest (publish while the flows land) is built and correct, byte-identical. It is not faster on this machine (R=1: 189 against 183 MB/s serial) because upload and replica traffic share the one hub broker. It is expected to pay off where clients and storage nodes reach the index over different links, which this machine cannot test.
State: 324/324 unit tests; live fabric 61/61
(eval/results/core_fabric_20260923-222652.json, index heap 2 GB, hub 4 GB).
Update 2026-09-26: block size on real imaging (OUT-10, D-C1-1)¶
Dedup and edit amplification were measured with the production chunkers and SHA-384 on real data on
the HPC cluster (SLURM array 223030, 32 tasks; lists 223029; merge 223031). A rerun on the same sample
(array 223196, merge 223197) reproduced every figure exactly and added exact size quantiles and read
amplification. Data was read in place. The de-identification edits were made in memory and never
written. Only aggregate numbers left the cluster: eval/results/dedup/ (*_r1.json, *_r2.json).
The tool is io.cresco.gfs.core.tools.DedupMeasure and the drivers are in eval/dedup/.
- DICOM. Every instance of the five TCIA renal collections: 410,546 instances, 194 GiB, median 528 KB, mostly CT slices. De-identification is PS3.15-style: names and IDs pseudonymised, birth date and accession blanked, dates shifted, UIDs remapped (lengths change). All 410,546 instances parsed.
- WSI. 837 GDC TCGA kidney SVS slides, 377 GiB, median 259 MB. The sample is stratified by
project and by diagnostic or tissue slide. The edit blanks the Aperio identifying keys (Filename,
Title, Date, Time, User, ScanScope ID, Barcode) in place (
desc), or moves a longer description to the end of the file (grow). None of the 837 slides has a label or macro image: GDC strips them.
| Chunker | DICOM dedup saving | DICOM de-id: new bytes per instance | DICOM refs per TiB | WSI dedup saving | WSI desc edit: new bytes per slide |
WSI refs per TiB |
|---|---|---|---|---|---|---|
fixed:65536 |
2.0 % | 499 KB (98 %) | 18.7 M | 0.34 % | 130 KB | 16.8 M |
fixed:1048576 |
0.8 % | 504 KB (99 %) | 2.2 M | 0.07 % | 1.7 MB | 1.05 M |
cdc:65536:8192:131072 |
1.4 % | 99 KB (20 %) | 17.2 M | 0.30 % | 186 KB | 17.1 M |
cdc:262144:65536:1048576 |
0.9 % | 278 KB (55 %) | 4.8 M | 0.07 % | 1.0 MB | 3.3 M |
cdc:1048576:262144:4194304 |
0.4 % | 470 KB (93 %) | 2.6 M | 0.03 % | 2.9 MB (0.6 %) | 0.84 M |
cdc:4194304:1048576:16777216 |
0.0 % | 506 KB (99.6 %) | 2.2 M | 0.01 % | 9.3 MB | 0.21 M |
Journal metadata at 130 bytes per reference: DICOM 2.2 GB per TiB at 64 KiB CDC against 0.33 GB at 1 MiB. WSI 2.2 GB per TiB against 0.11 GB.
What the data says:
- Real imaging does not deduplicate at any block size. DICOM saves at most 2.0 %, and WSI at
most 0.34 %, because the pixel data is already compressed or noisy. Smaller blocks buy almost no dedup.
- WSI favours 1 MiB clearly. Metadata is 20× smaller. A description edit stores 2.9 MB per
slide, 0.6 % of slide bytes, against 186 KB at 64 KiB.
- DICOM instances are about one block at 1 MiB. A median instance is 528 KB, so a 1 MiB block
holds the whole instance. A de-identified copy kept in the same dedup domain re-stores 93 % of each
instance at 1 MiB, against 20 % at 64 KiB CDC. Keeping original plus copy costs 1.93× at 1 MiB and
1.20× at 64 KiB. Metadata is 6.7× smaller, not 16×. This cost applies only where the edited copy
shares a domain with the original (a version in the same collection), or is a derived publish
through a grant from a 1 MiB base. A derived publish cuts its changed files at the base's
boundaries where that is safe (see the follow-up below), so a derivative of a 64 KiB base still
costs about 20 %. A copy in another domain with no grant is stored whole at any block size.
- Adopted: cdc:1048576:262144:4194304 for new domains (sealed tenants use the keyed form;
NONE uses fixed:1048576). The data does not argue for 64 KiB as the global default. It does
support a per-domain 64 KiB CDC for DICOM collections that will hold de-identified versions of the
same instances in-domain. Flag for the owner: that per-domain choice is available today
(chunker=cdc:65536:8192:131072 on core.domain); no automatic per-modality default is built.
- Config core_default_chunker sets the default for new domains. It must be a canonical, unkeyed
cdc: spec. An invalid value refuses the plugin start. The value belongs to the plugin instance's
own storage core (CoreService.defaultChunker, held by its PolicyEngine), never to the JVM, so
two gfs instances in one agent cannot change each other's default; the core logs it at start. A
domain records its spec at creation, and replay uses that record, so the change never re-cuts
existing data (DefaultChunkerTest, ExecutorConfigTest). No chunker size in a new domain may
exceed 16 MiB; a spec an earlier build recorded is honoured on replay (see the second follow-up).
- The cost stated plainly: scattered small edits. On the synthetic C1 workloads (32 MiB of random
bytes, eval/results/modules/chunkers.json), twenty 4 KiB overwrites re-store 18.4 MB at 1 MiB
against 2.0 MB at 64 KiB, and ten 300-byte insertions 21.0 MB against 0.6 MB. Imaging edits are
header-local, which is what the real-data run measured; a collection edited in scattered places
should choose a smaller chunker per domain.
- Matrix: 40 new cells at 1 MiB on 8–48 MB files, with a 700-byte insertion in the middle of 48 MB.
1 MiB CDC re-stores at most two maximum blocks; fixed 1 MiB re-stores everything after the edit.
The 180 64 KiB cells stay, for domains created before this change. The tier adds about 95 s to the
suite on a laptop.
Follow-up 2026-09-26: reads and derived publishes at 1 MiB (OUT-10 review)¶
An adversarial review of the change found three costs of larger blocks that the first pass had not measured or handled. All three are fixed or measured; the default stays 1 MiB.
- Derived publishes across specs. A grant bundle is keyed by the hash of the base's blocks, so a
derived publish finds unchanged bytes only if it cuts them at the base's boundaries. Before the
fix it always cut with the target domain's spec: a 16 MiB file derived from a 64 KiB base into a
new 1 MiB domain, with a 500-byte insertion, stored the whole file (50.3 MB at R=3) instead of
0.3 MB.
StorageEngine.derivedChunkersnow cuts changed files with the chunker of the base domain that holds most of the inherited bytes, when that reveals nothing the target's own chunker would not: fixed boundaries, both specs unkeyed, or a keyed base owned by the target's owner. A keyed or NONE target never takes public content-defined boundaries, and one tenant's boundary key never cuts another tenant's data. Those publishes use the target's chunker, reuse only whole identical blocks, and are counted inStats.derivedUnaligned(DefaultChunkerTest). - Streaming read memory. The read window was 256 blocks, so a stream held about three windows of
1–4 MiB blocks: 1–3 GiB. It is now bounded by
readWindowBytesas well (64 MiB, at least one block), about 200 MB per stream whatever the block size (ReadWindowTest). - Pieced downloads.
core.getserves a download as 8 MiB range reads, and a piece boundary inside a block fetched and opened that block twice: about one block in eight at 1 MiB. A download now reads through oneStorageEngine.RangeCursor, which keeps the last block of each range for the next. Grants and broken references are still checked for the kept block (ReadWindowTest).
Random range reads (rerun r2). A range read fetches every block it touches, whole. For a read of R bytes at a random offset, bytes fetched per byte asked are (size-biased mean block + R − 1) / R. For WSI, the tile rows replay every tile of every tiled image read alone and uncached, as a slide viewer without a cache would read them (45.4 M tiles, mean 8.9 KB).
| Chunker | DICOM 32 KiB read | WSI 32 KiB read | WSI tile read: fetched per tile | WSI tile amplification |
|---|---|---|---|---|
fixed:65536 |
3.0× | 3.0× | 74 KB | 8.4× |
fixed:1048576 |
17.8× | 33× | 1.06 MB | 119× |
fixed:4194304 |
20× | 129× | 4.2 MB | 473× |
cdc:65536:8192:131072 |
3.8× | 3.8× | 104 KB | 11.7× |
cdc:262144:65536:1048576 |
11.2× | 16.6× | 552 KB | 62× |
cdc:1048576:262144:4194304 |
18.2× | 63× | 2.13 MB | 240× |
cdc:4194304:1048576:16777216 |
24× | 249× | 8.4 MB | 949× |
At 1 MiB CDC the size-biased mean WSI block is 2.0 MB (DICOM 0.56 MB): JPEG tile data yields more long blocks than random bytes do. Sequential reads are unaffected (a whole-file read fetches each block once).
Flag for the owner: slide viewing. Serving viewer tiles straight from 1 MiB blocks, uncached,
moves about 240× the tile bytes from the storage sites, against 12× at 64 KiB. Dedup and metadata
still favour 1 MiB, and the owner accepted 1 MiB blocks, so the default stays. A deployment that
serves tiles from GFS should put a block cache in front of reads (tiles of one viewport sit in a few
blocks) or give viewer-facing pathology collections a smaller chunker per domain
(cdc:262144:65536:1048576 fetches a quarter as much; cdc:65536:8192:131072 a twentieth).
Follow-up 2026-09-26: second review¶
- The default is per instance. It was a JVM-wide static that every
ExecutorImplconstructor reset, so a storage instance built after the index silently changed the index's default for new domains.ExecutorImplnow only validates the key, and hands it to its ownCoreService, which carries it into every engine it builds, including after a snapshot install. - Recorded specs replay. The 16 MiB bound and exact field count applied on replay too, so a
domain an earlier build had accepted (for example
cdc:4194304:1048576:33554432, or a trailing field the old parser ignored) fenced the whole core on restart. Replay now goes throughPolicyEngine.restoreDomain, which checks the spec against what any earlier build could record (ChunkerSpec.checkRecorded); live creation stays strict. Writes cut with the recorded spec exactly as before, up toChunkerSpec.MAX_RECORDED_BLOCK(64 MiB); past it, that domain alone refuses writes and says why, and its data stays readable. A spec no build could record still fails replay and fences the engine. - Derived publishes choose the chunker per file. A changed path the base holds is cut with the chunker of the domain holding most of that file's bytes, under the same leak rules; a new path has nothing to match in the base and gets the target's own chunker. Before, a new 40 GB file derived into a 1 MiB domain from a 64 KiB base took 16× the block references for no dedup.
- The matrix record is opt-in.
IntegrationMatrixTestwrites totarget/results/modules/by default;-Dgfs.results.dir=eval/results/modulesrefreshes the tracked record deliberately. - Measurement hygiene.
DedupMeasureblock keys are HMAC-SHA-384 under a per-run secret, not plain SHA-384 prefixes, so a leftover key file cannot confirm a guessed header block. The shard work files are owner-only, and the merge deletes the block keys and sizes once its JSON is written (the driver deletes the run key). The r1 and r2 work files on the HPC cluster were deleted.
Label edits. The 837 GDC slides carry no label or macro image (GDC strips them). A first
read-only search (job 223130) for scanner-native slides elsewhere timed out; a second, narrower one
(job 223618) found seven Philips iSyntax slides on the HPC cluster with label and macro images. Label edits
were measured on those (job 223712, eval/dedup/isyntax_label.sbatch, run on node-local scratch)
and on the six public OpenSlide Aperio test slides (CC0 or freely distributable, no patient data;
eval/dedup/openslide_label.sh). The edit zeroes the label and macro images and blanks identifying
metadata, same length, in place.
| Chunker | iSyntax label: new bytes per slide (p95) | iSyntax metadata only | Aperio label: new bytes per slide (p95) | Aperio description only |
|---|---|---|---|---|
fixed:65536 |
309 KB (371 KB) | 66 KB | 362 KB (441 KB) | 131 KB |
cdc:65536:8192:131072 |
273 KB (371 KB) | 67 KB | 601 KB (741 KB) | 178 KB |
cdc:262144:65536:1048576 |
1.97 MB (4.19 MB) | 257 KB | 1.69 MB (2.10 MB) | 1.01 MB |
cdc:1048576:262144:4194304 |
4.54 MB (7.05 MB) | 1.22 MB | 4.32 MB (7.05 MB) | 3.09 MB |
cdc:4194304:1048576:16777216 |
18.4 MB (23.7 MB) | 3.78 MB | 9.80 MB (14.1 MB) | 7.96 MB |
- iSyntax: 7 slides, 9.93 GiB, median 1.56 GB; the label and macro images are base64 in the XML header (174 KB per slide). At 1 MiB a label edit stores 0.3 % of slide bytes; at 64 KiB, 0.02 %. Block references per TiB: 17.0 M at 64 KiB CDC against 0.83 M at 1 MiB. Dedup saving at most 1.0 %.
- Aperio: 6 slides, 1.22 GiB, median 178 MB (441 KB edited per slide). At 1 MiB a label edit stores 2.0 % of slide bytes; at 64 KiB, 0.3 %. The description edit alone costs 3.09 MB at 1 MiB, in line with the 2.9 MB measured on the GDC slides.
- A label edit costs 4-5 MB per slide at 1 MiB, about 16× (iSyntax) and 7× (Aperio) the 64 KiB cost. Against slides of 0.2-2 GB that is 0.3-2 % of slide bytes. It does not change the verdict.
- Not measured: other vendors' layouts (NDPI, MRXS, DICOM-WSI) and a larger iSyntax sample.
Update 2026-09-26 (evening): format adapters (D-C1-1)¶
A de-identified DICOM copy re-stored 93 % of each instance at 1 MiB CDC, because a median instance
is one block. Format adapters remove that cost. An adapter recognises one audited format and names
the offsets where a block must begin; the inner chunker cuts each region between them as a whole
file. So a metadata edit, even one that changes lengths, leaves the bulk blocks unchanged at any
block size. The framework, the contract, the audit checklist, version pinning and the measurements
are in FORMAT-ADAPTERS.md. The code is core/chunk/format/.
- Adapters.
dicom@1cuts at the top-level pixel data element.tiff@1cuts at IFD and ImageDescription regions, and at image data that lies after its IFD.nifti@1cuts atvox_offset. A spec pins exact versions (fmt:dicom@1,nifti@1,tiff@1+<inner>). An adapter or version not on the allowlist refuses domain creation. The engine's segmented, parallel path cuts each region on its own, so no format needs the sequential path. - Measured on the HPC cluster (array 223418, merge 223419, bench 223420; plain-CDC figures equal r1):
| Corpus and edit | cdc 1 MiB |
with the adapter | Block refs per TiB, without → with |
|---|---|---|---|
| DICOM, 410,546 instances: de-id copy | 470 KB (92.6 %) | 4.0 KB (0.8 %) | 2.57 M → 4.73 M (64 KiB CDC: 17.2 M, with 99 KB per copy) |
| WSI, 837 SVS: description edit | 2.87 MB | 1.1 KB | 0.84 M → 0.88 M |
| WSI: longer description appended | 4.32 MB | 1.08 MB | |
| WSI: associated image removed | 2.97 MB | 2.02 MB | |
| NIfTI, 123 KiTS23, decompressed: descrip edit | 2.10 MB | 352 B | 0.80 M → 0.80 M |
NIfTI as stored (.nii.gz): descrip edit re-compressed |
67.5 MB (80 %) | 67.5 MB (80 %) |
Files no adapter matches are cut exactly as before: every SVS block is identical under
fmt:dicom@1. Throughput is unchanged within the ±10 % noise of a shared node (DICOM engine path
830 → 855 MB/s, WSI 848 → 820 MB/s, NIfTI 836 → 858 MB/s).
- Adopted: fmt:dicom@1,nifti@1,tiff@1+cdc:1048576:262144:4194304 for new unkeyed domains
(ChunkerSpec.DEFAULT_CDC). The numbers support it clearly: every format's edits get far cheaper,
nothing else changes, and throughput holds. DICOM pays one block more per instance and still has
3.6× fewer references than 64 KiB CDC. Sealed tenants keep plain keyed-cdc: a format cut
sits at a public, content-determined offset (a header's length), which keyed boundaries exist to
hide. NONE keeps fixed:1048576. Existing domains keep what they recorded.
- Not adopted: dicom-frag@1 (cuts at encapsulated fragments) and tiff-tiles@1 (cuts at every
tile) stay measurement-only. The renal corpus has no encapsulated pixel data, so fragment cuts
could not be tested on real data. Every GDC tile lies before its IFD, so a streaming chunker cannot
cut there.
- Flags for the owner.
- (1) NIfTI dedups only if stored uncompressed: KiTS23 on the HPC cluster is .nii.gz only, and an edit
to a gzip stream re-stores 80 % of it at any block size.
- (2) Pixel data can itself identify: burned-in text, or a face reconstructed from head CT/MR.
Bulk blocks therefore follow the domain's dedup policy like any other block.
- (3) Which adapter cut a file is not yet recorded per file. FileEntry.canonical() feeds the
root hash, so the field waits for the wave-2 FileEntry change; a proposal is in
FORMAT-ADAPTERS.md.
- (4) Label edits on real slides were measured at plain CDC (iSyntax and Aperio, "Label edits"
above). With the adapters, label removal was measured only on the GDC thumbnail, which stood in for
a label image; it remains to be measured on the iSyntax and Aperio slides.