access patterns & aggregations#
how reads work over an atproto-repo-canonical store, calibrated against the
cloud prototype (prefectlabs/object-store-orchestration, guidry's proofs/
directory) so the two designs can be compared honestly.
what the cloud prototype proved (and what we borrow)#
the prototype's GKE simulation (p95/whale workspace, ~440 writes/s, 30 query patterns) sorted every read into three latency bands:
| band | what answers it | examples (their p50) |
|---|---|---|
| sub-30 ms | redis directly, or pod-local index cache | active_by_state 14ms, deployment reads 5ms |
| 100–500 ms | iceberg hour-partition scans through a byte-range cache | completed_1h 112ms → completed_24h 279ms |
| 500–900 ms | delta-merge dominated (hot rows not yet flushed) | task_runs_24h 869ms, by_tag_24h 918ms |
three of their findings transfer directly:
- the live-state index is a membership structure, not a table.
their
active_by_stateis a redis ZSET; ours is a burner-redis set per(kind, state). same shape, same O(members) cost, same answer to "what is running right now." - the latency knob is unmerged-delta size, not file count. their
scan_redis_deltasfinding (5-min flush → 830ms; 30-s flush → ~460 deltas and falling). our analog is commit cadence: everything in the appview is always "flushed" (synchronous dual-write), so we sit at the degenerate happy case — at the cost of write latency, which is the trade to benchmark. - many small objects is death. their disproven
parquet-indexesproof: 500 individual delta files = 52–95× degradation. our analog decision: one CAR, batched commits — never a file per record. (the full-CAR-rewrite-per-commit we do today is the opposite extreme and fails at the other end; block-level append is the meeting point.)
their explicitly open item: "we haven't run the same queries against the
production Postgres to compare head-to-head." that's the gap our
benchmark fills at small scale (see scripts/bench-reads).
the read surface, mapped#
| pattern (their name) | their path | our path |
|---|---|---|
run_point_lookup |
redis hash / bucket JSON | appview row get (flow_run:<id>) |
active_by_state |
redis ZSET slice | s:flow_run:state:RUNNING set → rows |
terminal_completed_* |
iceberg partition scan + delta merge | s:flow_run:state:COMPLETED set → rows, ISO-string sort |
paginated_* |
OFFSET over iceberg scan | sort + slice (same O(n), honest about it) |
by_flow, by_deployment |
IndexTable predicate pushdown | s:flow_run:flow:<id> membership set |
deployment_list |
IndexTable (parquet base + redis delta) | s:flow:all → rows, name sort |
count_by_state |
ZSET cardinality | scard — O(1) |
time-window scans (completed_1h) |
hour-partition pruning | TID-range scans (designed, not built): event rkeys are TIDs, and the MST is ordered — a key-range walk from TID(t0) to TID(t1) is partition pruning by construction |
sorting note: ISO-8601 timestamps sort correctly as strings, so the appview never parses dates on the read path.
aggregations: three tiers#
tier 1 — maintained aggregates (answers in O(1)).
counts by state, by queue, by deployment: set cardinalities updated on every
transition. this is the cloud prototype's redis counter layer and it covers
the dashboard's top row. cheap to add: per-day counters
(c:flow_run:completed:2026-06-12) bumped on terminal events.
tier 2 — bounded scans (answers in O(candidates)).
everything /filter shaped: membership-set intersection → row loads → sort
→ slice. fine to ~10⁵ rows per workspace in-process. this is where we live
today and where the benchmark measures us.
tier 3 — analytical queries (answers in O(data), offline-friendly).
the cloud answer is iceberg + datafusion, load-bearing for BYOS: "point any
engine at your bucket." our answer is the same argument with a different
open format: the repo CAR is the export. getRepo → decode records →
parquet → duckdb/datafusion/polars. the pipeline is trivially scriptable
(just export-car && duckdb), needs zero server support, and inherits the
verifiability story — you can prove the parquet faithfully derives from the
signed repo. group-bys over months of history don't belong on the
orchestration hot path in either design.
the event log makes tier 3 unusually clean here: io.prefect.v0.event is
already the fact table (occurred, event name, resource id, payload), TID
rkeys give natural time partitioning, and entity records are the dimension
tables. it's a star schema by accident.
what we deliberately don't do#
- no SQL pushdown into the appview — filter shapes are compiled by hand
(the cloud prototype compiles prefect filter schemas into datafusion
plans; see their
filter-schema-compilation.md. if our filter surface grows past a handful of shapes, that's the design to copy.) - no time-range filters yet (needs TID-range walks or a created-time zset)
- no cross-entity joins on the hot path —
flow_runs joined to deploymentsresolves through membership sets or moves to tier 3
benchmark#
scripts/bench-reads seeds an identical workload through the REST API of
each target, then measures the shared read patterns. honest caveats baked
in: same process, same machine, sequential requests, demo scale — this
measures architecture overhead, not cloud scale. results in
docs/bench-results.md.