Two autonomous end-to-end runs of EKOS's object-storage-backed, horizontally-distributed knowledge ledger — fault injection at the coordinator, the compile worker, and the query gateway. Run 1 found four defects. Run 2 — harder environment — found four more. All eight are fixed, with regression tests and zero workspace regressions.
KnowledgeStore traitEKOS is a compiler for enterprise knowledge: it observes source systems, compiles the observations through deterministic passes into a Canonical Knowledge Model, and stores the result in an append-only, evidence-carrying ledger. Distributed mode splits that ledger across many independent fact-segment partitions in object storage and puts a metadata coordinator, a compile worker, query workers, and a read-only gateway around it — every existing reader keeps working because the gateway implements the same trait the local ledger does.
| Component | RFC | Role | Crate |
|---|---|---|---|
| PartitionedLedger | 0111 A | Drop-in KnowledgeStore routing across many FactLedger partitions by entity kind + time bucket; persisted catalog, run-file index, cold tiering. | ekos-ledger |
| SegmentBackend seam | 0113 B1 | Interface behind which sealed-segment reads/publishes go. LocalFsBackend (default) or… | ekos-segment-backend |
| ObjectStoreBackend | 0113 B2 | …an object_store 0.14 backend — S3, Azure ADLS, S3-compatible (MinIO), in-memory, file://. Feature-gated. | ekos-segment-backend |
| Coordinator | 0113 B3 | Metadata service over newline-delimited JSON-RPC/TCP: fencing-tokened write leases, per-shard commit watermarks, partition catalog, entity→partitions pruning index, atomic JSON persistence. | ekos-cluster |
| CompileWorker (Service A) | 0113 B3 | Runs the real build → recover → resolve → compile → commit pipeline under a heartbeated lease, then registers its partitions and commits the new generation — fenced. | ekos-cluster |
| QueryWorker (Service B) | 0113 B4 | Stateless read compute: materialises a partition into a local cache (from object storage or a local root), opens it read-only, serves the EAV fold + tantivy search over TCP. | ekos-distributed |
| DistributedLedger gateway (Service C) | 0113 B4 | Read-only KnowledgeStore: pooled connections, concurrent multi-partition fan-out, entity→partitions id-pruning, per-shard BM25 top-k search merge, worker failover. | ekos-distributed |
Each run works through an executable test plan act by act. Every command's stdout,
stderr, and exit code is captured to disk; metrics land in metrics/*.json; a
REPORT.md is generated from the logs, not from memory. A step failure is recorded
as-is — no softening, no "fixing" a result before recording it.
query object, query find, diff against the local partitioned ledger — the control numbers.kill -9 the holder, takeover.| Run 1 — baseline | Run 2 — hardened | |
|---|---|---|
| run id | run-20260831T212115Z | run-20260831T222159Z |
| binary | stock e8e1ca3 | e8e1ca3 + 18-file fix diff |
| workspace | Pentaho ETL — 20 files (.ktr/.kjb/SQL/PDF) | Plausible (Elixir) — 895 .ex/.exs in scope, 2 006 files copied |
| object store | file:// fixture dir | s3://ekos-partitions/run2c on MinIO — real S3 API over HTTP |
| partitions | 12 | 95 |
| objects | 243 | 6 999 |
| LLM | skipped — structural only | [document-semantics] via OpenAI — 250 concepts |
| lease TTL | 30 s (default) | 8 s — recovery measured by polling, not slept through |
| query workers | 2 · caches stayed empty (shared FS) | 2 · caches pulled 12–13 MB each from MinIO |
Run 1's fixes made the object-store path reachable through the CLI at all. Running that path against a real S3 endpoint, a 95-partition workspace, an 8-second lease, and concurrent RPC then exposed a second layer of defects that unit tests had never touched.
Fixing the object-store CLI panic (defect 1) let ekos compile-worker reach a real
S3 endpoint — which then failed to authenticate (1b), and once authenticated published partitions
that a remote query worker read as empty (6), under a lease its own heartbeat couldn't sustain (7),
over a connection two concurrent calls corrupted (7b). None of these are visible until the whole
path runs end to end against real infrastructure.
A fresh workspace with [storage.partition] set compiles into
many independent fact-segment partitions keyed by entity kind and week, with a persisted
catalog and an entity→partitions index. Run 2 also publishes every partition
to object storage.
entity→partitions entries18 608Entity-kind partitioning makes one partition per ObjectKind and per relationship
kind. The [document-semantics] analyzer's LLM relationship extraction emits free-form
kinds — bare prepositions (rel:from, rel:with) and phrases with spaces
(rel:can be downloaded via) — so the count ballooned. The object-store key path
handled the space-containing keys without complaint; the analyzer's vocabulary is the thing to
tighten (tracked, not a storage defect).
| Stage | Run 1 | Run 2 | Notes (Run 2) |
|---|---|---|---|
| build | 10.59 s | 38.38 s | 1 135 files observed |
| recover | 0.62 s | 5.29 s | Elixir AST + document-semantics via OpenAI (cached) |
resolve --force | 0.09 s | 0.70 s | 19 identity conflicts (e.g. error as module & symbol) — continue anyway |
| compile | 0.37 s | 2.80 s | 13.8k candidate pairs, 171 auto-merged |
| commit | 19.43 s | 235.35 s | 95 partitions × manifest/segment PUT over HTTP — the S3 round-trips dominate |
Three reads through the local PartitionedLedger: fetch an object
by id, full-text find, and a full-range diff. These are the baseline Act 4 is measured against.
| Operation | Run 1 | Run 2 | Result |
|---|---|---|---|
query object <id> | 0.109 s | 0.45 s | Run 2: Plausible.Billing (ElixirModule), properties + evidence |
query find | 0.109 s | 1.55 s | Run 1: 16 hits for "customer" · Run 2: 113 hits for "Subscription" |
diff (full range) | 0.676 s | 27.55 s | Run 1: 1 292 versions · Run 2: 18 608 entities touched, resolved to name (kind) |
diff fans to all 95 partitions with no time-bucket pruning for a wide range — the slow number here, and in Act 4.ekos diff used to print opaque entry #0 … entry #N (per-backend row
ids, restarting per partition). It now resolves each touched logical id to its name and kind,
capped at 50 with an "… and N more".
The SegmentBackend trait is the seam. Run 1 proved the LocalFs
default is untouched (146 ledger tests) but the object-store path panicked the moment
the CLI opened it. Run 2 runs the entire pipeline against a real S3 endpoint.
segment-backend-url = "file://…"
$ ekos build thread 'main' panicked at object_store_backend.rs Cannot drop a runtime in a context where blocking is not allowed. 7: drop_in_place<ObjectStoreBackend> 8: store::with_segment_backend_url store.rs:132 11: build::run 12: ekos::main <#[tokio::main]>
The backend owned a tokio Runtime, dropped inside the async
main. Every write-path command panicked; object storage was unreachable via the CLI.
segment-backend-url = "s3://ekos-partitions/run2c"
$ AWS_ENDPOINT=http://127.0.0.1:9100 \ AWS_ALLOW_HTTP=true ekos build && … && ekos commit Commit complete. Objects written: 6999 Relationships written: 8533 Ledger: .ekos/ledger/partitioned $ mc ls --recursive ekos-partitions/run2c | wc -l 631 # 95 × manifest.json + dict.bin, 8.4 MiB
Backend now runs its object_store calls on a dedicated OS-thread
runtime — safe to build, call, and drop from any context.
ekos-ledger147ekos-segment-backend --features object-store9ekos-cluster (unit + harness)12ekos-distributed14cargo test --workspacegreen · 0 failedclippy -D warnings · fmt --checkcleancompile-workermanifest.json · dict.binsegments/seg-000000.factsHEAD watermarksearch/ (tantivy)ElixirModule/2026-W36292 KiB segment + ~130 KiB indexThe plan's file:// fallback ("flag that it's not a full check") is no longer needed. Not covered: a real hyperscaler — MinIO is S3-API-compatible but self-hosted; IAM, regions, and TLS against AWS/Azure/GCS are untested.
Worker A holds a fencing-tokened write lease. Worker B, on the same shard,
must be rejected. kill -9 A — a new worker must acquire with a higher token,
finish the pipeline, and commit the generation. Run 2 measures the recovery time by polling,
at an 8-second TTL.
metrics/fencing.jsonRun 2coordinator status$ ekos coordinator status partition cold location ElixirModule/2026-W36 false s3://…/run2c/ElixirModule/2026-W36 … 94 more, all s3:// … shard generation main 25499
state.json partitions95 · all s3://watermarks{"main": 25499}entity_partitions18 608 entriesThe same scenario at 8 s TTL before the fix: worker C took over in 0.5 s (A's lease
was already perpetually near-expired, because the heartbeat was a fixed 10 s > the 8 s TTL)
and then lost its own lease before committing — watermarks {}. The fix
derives the heartbeat from lease.expires_at; an 8 s lease now survives a
multi-minute pipeline. Run 1's 30 s TTL had masked this entirely.
Run 1 result, for the record: worker B rejected (exit 1); token 1 → 2; generation 1720; 12 partitions; recovery bounded by the 30 s TTL (the 41 s wall figure included a 40 s scripted sleep — Run 2's polling removes that artefact).
Two query workers start with empty caches. Because the partitions are
registered as ObjectStore locations, each worker materialises what it needs
from MinIO — not a shared filesystem. Then one worker is killed mid-query.
PartitionedLedgerDistributedLedger gatewayPartitionLocation::ObjectStorequery object0.59 s → Plausible.Billingquery find "Subscription"0.84 s · 50 hitsdiff12.10 s · 18 608 entities# kill -9 query-worker-1 (7811); worker-2 up $ ekos query object <id> Error: io error: Connection refused (os error 111) exit 1 $ ekos query find … Error: ledger error: Connection refused exit 1
Every query failed — the retry reconnected to the same dead address.
call_worker_failover
# kill -9 query-worker-1 (7811); worker-2 up $ ekos query object <id> WARN gateway: query worker unreachable — failing over worker=127.0.0.1:7811 Object: Plausible.Billing (ElixirModule) exit 0 $ ekos query find "Subscription" 50 result(s) exit 0
Served from worker-2; recovers fully when worker-1 restarts. Clean error only when all workers are down.
| Operation | Local | Gateway | Δ | Run 1 (12 part) |
|---|---|---|---|---|
query object <id> | 0.45 s | 0.59 s | +0.14 (1.31×) | 0.069 s vs 0.109 s local |
query find | 1.55 s | 0.84 s | −0.71 (0.54×) | 0.064 s vs 0.109 s local |
diff (full range) | 27.55 s | 12.10 s | −15.45 (0.44×) | 0.254 s vs 0.676 s local |
PartitionedLedger open with 95 tantivy writers. query object pays one extra RPC hop.The gateway fans each shard's BM25 top-k to a worker and merge-sorts the scored lists. Term statistics are per-partition (IDF is shard-local), so a match in a small partition can outrank a more globally-relevant match in a large one. This is the documented v1 approximation, not a defect — and both runs reproduce it.
| Rank | Local — PartitionedLedger | Distributed — gateway |
|---|---|---|
| 1 | subscription — Concept (LLM) | plausible/billing/subscription.ex — File |
| 2 | subscription flow — Concept | Money.Subscription — ElixirModule |
| 3 | subscription business model | mix/tasks/cancel_subscription.ex |
| 4 | Plausible | plausible/billing/subscription/status.ex |
| 5 | Money.Subscription | mix/tasks/pull_sandbox_subscription.ex |
"Subscription", Run 2. Local ranks free-form LLM Concept objects top; the gateway ranks real Elixir files/modules top — the small Concept shard's local IDF differs from the corpus-wide one.find_objects cap (open item)Run 1, term "customer": local and gateway returned the same 16-item de-duplicated set; local put the whole PDF first, the gateway put the small TransformNode shard's 10 hits first. Canonical test crates/distributed/tests/search.rs passes.
The benchmark/ workspace has 11 Criterion benches. A grep over
benches/ for PartitionedLedger, SegmentBackend,
DistributedLedger, ekos_cluster, ObjectStoreBackend
returns nothing. That gap is now a verified fact, not an assumption.
ledger_append_object928 µs [906 / 928 / 954]segment_append_batch2.17 ms [2.12 / 2.17 / 2.24]segment_replay_1k_batches4.83 ms [4.78 / 4.83 / 4.89]Criterion printed change: deltas against a
prior local baseline, but those compare an uncontrolled earlier run at a different
measurement window — not a before/after of this patch.
metrics/criterion_distributed_coverage.txt$ grep -rl 'PartitionedLedger|SegmentBackend| DistributedLedger|ekos_cluster| ObjectStoreBackend' benchmark/benches/ NO distributed / partitioned / cluster / object-store code referenced by any benchmark/benches/*.rs file
All distributed-path timing in this report is CLI
wall-clock (Acts 0–5), carrying process-startup and RPC-framing overhead a real Criterion
bench would isolate. Authoring partitioned_ledger.rs /
cluster_coordinator.rs benches is separate work.
Every fix ships with a regression test. Branch
fix/distributed-storage-issues, commit 2896481 — 23 files,
+885 / −92. cargo test --workspace, clippy -D warnings, and
fmt --check all pass.
[storage.partition] segment-backend-url + a --features distributed binary → ekos build/commit panic: Cannot drop a runtime in a context where blocking is not allowed. Object storage was unusable through the CLI at all.Runtime; it (or a throwaway one built just to validate the URL) was dropped inside #[tokio::main], where BlockingPool::shutdown panics.DedicatedRt — a current-thread tokio runtime pinned to one private OS thread (ekos-objstore-rt). Every object_store call spawns onto it and blocks on an mpsc reply; the Runtime is only ever dropped on its own thread. Safe to build/call/drop from a plain sync test, a spawn_blocking thread, a current-thread runtime, and #[tokio::main]. store.rs now validates the URL parse-only.object_store_backend::usable_from_within_an_async_runtimeparse_url reads no configurationRun 2 · s3:// never authenticated to MinIORun 2s3:// URL against MinIO (or any non-AWS endpoint) never authenticated — feature for AmazonS3 not enabled, then unconfigured-credential failures.object_store::parse_url returns an unconfigured AmazonS3Builder::new(); it reads no env vars. The object-store cargo feature only pulled object_store/fs.AWS_* / AZURE_* / GOOGLE_* / OBJECT_STORE_* process var (lowercased) to object_store::parse_url_opts — builder_opts! silently drops keys a scheme doesn't recognise, so it's safe for all backends. The feature now bundles object_store/aws + object_store/azure.compile-worker against s3://… on MinIO with AWS_ENDPOINT / AWS_ACCESS_KEY_ID / AWS_ALLOW_HTTP=true.kill -9'd query worker → every DistributedLedger read fails io: Connection refused, exit 1 — even with a second live worker holding the same partitions.call_worker's single retry reconnects to the same address.DistributedLedger::call_worker_failover — on a connection error, rotate to the next worker in the ring (every worker can materialise any partition). Non-connection errors return as-is; clean error only when all workers are down.gateway_fails_over_when_a_query_worker_is_down · gateway_errors_cleanly_when_all_workers_are_downcoordinator status always shows watermark 0Run 1 · cosmeticRun 1status queried watermark(partition_id) per catalogued partition; watermarks are keyed by shard (main), so every row read 0.Request::Watermarks RPC + CoordinatorClient::watermarks(); status prints a "shard / generation" section. Verified main 25499.ekos diff prints opaque entry #NRun 1 · cosmeticRun 1LedgerDiff.added holds per-backend entry ids (SQLite rowids / per-partition tx numbers); the counter restarts per partition.LedgerDiff.touched (real logical ids) instead — resolve each to name (kind) / relationship label, capped at 50 with "… and N more".[llm-description] ignores the provider configRun 2 · 1 112 × HTTP 401Run 2[llm] provider = "openai" — but the OpenAI key was sent to api.anthropic.com: 1 112 × authentication_error.select_llm_provider_for_description handled ollama then fell through to AnthropicProvider; the openai branch that recover.rs has was missing.openai branch, mirroring recover.rs::build_llm_provider. Descriptions then succeed via OpenAI (0 × 401).manifest.json. A remote-only query worker saw it as empty.manifest.json, dict.bin, and search/. The active (unsealed) segment — where all the data is when nothing seals — stayed writer-local by design.SegmentStore::publish_active (active segment + HEAD) + FactLedger::publish_active_to_backend + PartitionedLedger::publish_active_segments, called by compile-worker's finalize_partitions; open_with_backend pulls the active segment when the local one is absent.active_segment_travels_through_the_backend — default 8 MiB threshold, nothing seals, a fresh reader still sees every row.--ttl-seconds 8: the lease expires between every 10 s beat → guard.commit(watermark) fails LostLease, the generation is never recorded, even with no competing writer.CompileWorker::new hard-codes Duration::from_secs(10) regardless of the coordinator's TTL. The 30 s default made it work by luck.lease.expires_at - now (≈ TTL/3, floored 500 ms, never slower than the 10 s default).heartbeat_adapts_to_a_short_ttl_so_long_work_keeps_its_lease — 1 s TTL, 4 s of work, default heartbeat → commit succeeds.Coordinator("unexpected Ok")Run 2CoordinatorClient::call / QueryWorkerClient::call guard write and read with separate mutexes; caller B can take the read lock between caller A's write and read and consume A's response line. Fires when a worker's heartbeat lease_renew races its guard's manifest_commit, or the gateway fans out concurrently.ekos compile-worker run --forcenew flag · Service-A equivalent of ekos resolve --forcegaperror as both an ElixirModule and an ElixirSymbol). A co-located ekos resolve already had --force; Service A did not."PASS (new)" marks behaviour introduced by this campaign's fixes. "PARTIAL" marks a feature exercised but not fully — noted, not hidden.
| Feature | RFC | Exercised by | R1 | R2 | Status |
|---|---|---|---|---|---|
| Partitioned ledger — entity-kind × weekly | 0111 A | full pipeline; catalog + index inspected | 12 | 95 | PASS |
SegmentBackend seam / LocalFsBackend | 0113 B1 | 147 ledger tests + every local op | ✓ | ✓ | PASS |
ObjectStoreBackend — object_store 0.14 | 0113 B2 | file:// (R1) → S3/MinIO (R2) | ✕ | ✓ | PASS |
| Coordinator — fencing-tokened write leases | 0113 B3 | worker B rejection; token increment | ✓ | ✓ | PASS |
| Coordinator — per-shard commit watermark | 0113 B3 | generation 1720 / 25499; state.json | ✓ | ✓ | PASS |
Coordinator — entity→partitions prune index | 0113 v1.1 | 1 292 / 18 608 entries; prune test | ✓ | ✓ | PASS |
| Coordinator — persisted state / restart-safe leases | 0113 B3 | state.json round-trip; harness | ✓ | ✓ | PASS |
| CompileWorker (Service A) — real pipeline under lease | 0113 B3 | build→…→commit · --force | ✓ | ✓ | PASS |
| CompileWorker — lease loss & takeover | 0113 B3 | kill -9 A; worker C resumes | ✓ | ✓ | PASS |
| CompileWorker — adaptive heartbeat | fix 7 | survived --ttl-seconds 8 + full pipeline | — | ✓ | PASS · new |
| QueryWorker (Service B) — materialise from S3 | 0113 B4 | w1/w2 caches 12–13 MB from MinIO | — | ✓ | PASS · new |
QueryWorker — read-only FactLedger + EAV fold | 0113 B4 | every gateway read | ✓ | ✓ | PASS |
| DistributedLedger gateway (Service C) — trait parity | 0113 B4 | object/find/diff vs local | ✓ | ✓ | PASS |
| Gateway — connection pool + concurrent fan-out | 0113 v1.1 | multi-partition reads over 95 shards | ✓ | ✓ | PASS |
| Gateway — id-scoped pruning via entity index | 0113 v1.1 | query object routing; prune test | ✓ | ✓ | PASS |
| Gateway — worker failover | fix 2 | kill worker-1; served by worker-2 | ✕ | ✓ | PASS · new |
| Distributed search — per-shard BM25 top-k merge | 0113 B5 | "customer" / "Subscription" ordering | ✓ | ✓ | PASS · caveat |
| Self-describing partition — sealed segments in S3 | 0113 B4 | few partitions reached 8 MiB | — | ~ | PARTIAL |
Self-describing partition — active segment + HEAD in S3 | fix 6 | 95 × seg-000000.facts in MinIO | — | ✓ | PASS · new |
Self-describing partition — tantivy search/ in S3 | 0113 B4 | ElixirModule 130 KiB index pulled by workers | — | ✓ | PASS |
[storage.distributed] client config | 0113 B4 | client-ekos.toml + EKOS_CONFIG | ✓ | ✓ | PASS |
OpenAI provider — recover / document-semantics | 0026 / 0046 | 250 concepts, 114 rels from README + CHANGELOG | — | ✓ | PASS |
OpenAI provider — [llm-description] | 0088 / fix 5 | module descriptions via OpenAI, 0 × 401 | — | ~ | PASS |
| Criterion micro-benchmarks + coverage grep | — | 11 benches run; coverage.txt | — | ✓ | PASS · 0 dist. coverage |
AWS_ALLOW_HTTP=true for the local endpoint.127.0.0.1. Network partitions and cross-host clock skew not exercised — but the workers genuinely materialise from object storage, so "shared filesystem" is no longer a caveat.[llm-description] not run to completion. 1 112 sequential gpt-4o-mini calls (~30 min). Exercised far enough to prove defect 5. recover's own LLM path ran fully.guard.commit is rejected.ekos compile-worker has no acquire-retry. It exits on already leased; fault-tolerant takeover needs an external supervisor (the test driver's loop stood in).[document-semantics] relationship vocabulary. One partition per bare preposition (rel:from, rel:with) — tighten the analyzer.find_objects is hard-capped at search(query, 50) — 113 vs 50 local for "Subscription".ekos diff over many partitions fans to every one (12–27 s); no time-bucket pruning for a wide range.# write side — Service A, under a coordinator ekos coordinator serve --listen 127.0.0.1:7801 --state coord.json --ttl-seconds 8 ekos compile-worker run --coordinator 127.0.0.1:7801 --shard main --workspace WS --force ekos coordinator status --coordinator 127.0.0.1:7801 # read side — Services B & C ekos query-worker serve --coordinator 127.0.0.1:7801 --listen 127.0.0.1:7811 --cache CACHE EKOS_CONFIG=client-ekos.toml ekos query object <id> EKOS_CONFIG=client-ekos.toml ekos query find "Subscription" EKOS_CONFIG=client-ekos.toml ekos diff --from 2020-01-01T00:00:00Z --to <now>
ekos.toml · Run 2[storage.partition] dimension = "entity-kind" time-bucket = "weekly" segment-backend-url = "s3://ekos-partitions/run2c" [llm] provider = "openai" model = "gpt-4o-mini" [document-semantics] enabled = true
client-ekos.toml[storage.distributed]
coordinator = "127.0.0.1:7801"
query-workers = [
"127.0.0.1:7811",
"127.0.0.1:7812",
]
# env for the object store (MinIO)
AWS_ENDPOINT=http://127.0.0.1:9100
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…
AWS_ALLOW_HTTP=true
fix/distributed-storage-issuestest-runs/ ├── run-20260831T212115Z/ # Run 1 — stock e8e1ca3, Pentaho, file:// │ ├── REPORT.md │ ├── logs/{00-environment … 61-search-rs-test}.log │ └── metrics/{baseline,fencing,latency_comparison,search_ordering,…}.json └── run-20260831T222159Z/ # Run 2 — + fix diff, Plausible/Elixir, MinIO ├── REPORT.md ├── logs/00b-bugfix-diff.patch # the 18-file fix ├── logs/{10-baseline … 70-criterion-bench}.log └── metrics/{baseline,fencing,latency_comparison,search_ordering, benchmark_summary,criterion_distributed_coverage}.{json,txt}
Both runs carry a generated REPORT.md built from the logs. Narrative and rationale: devlogs/devlog_144.md.