Skip to content

perf: the /data read surface in milliseconds (#219) - #220

Merged
rorybyrne merged 8 commits into
mainfrom
219-perf-data-read
Aug 16, 2026
Merged

perf: the /data read surface in milliseconds (#219)#220
rorybyrne merged 8 commits into
mainfrom
219-perf-data-read

Conversation

@rorybyrne

Copy link
Copy Markdown
Contributor

Closes #219.

Every answer the /data read surface serves — table pages, manifests, the node catalog, SKILL.md, MCP views — took ~10s at Pockets scale (213k records / 5.1M feature rows) because each request recomputed whole-table aggregates, fetched unbounded sorted result sets, and emitted sort orderings no index can serve. This PR removes all per-request O(table) work, one phased commit per issue phase:

Commit Issue phase
deb4535 1 — resolve_table stops building manifests (columns from catalogs, zero count() pinned by statement capture)
6d9f51f 2 — LIMIT pushdown via BoundedPage | FullStream (unbounded reads opt-in by name; plain execute() for pages, server-side cursor kept for dumps)
e3864c1 3 — index-servable sorts: nullability-aware NULLS emission, row-value keyset predicates, composite index records (schema_id, schema_version, published_at, srn) built CONCURRENTLY (migration f3a1c9d27e54); EXPLAIN pins no-Sort plans; cursor wire-compat pinned with pre-change-minted tokens
e3d89ca 4 — feature DML joins the unit of work (last engine-connection DML in the system removed; stage atomicity now provable)
a91fcc4 5 — table_statistics maintained by lockstep upsert inside the writing adapter's transaction; ON CONFLICT-aware deltas; redo nets zero; backfill migration a8c4e6f19b02
fe75f13 6 — all surfaces read table_statistics (algebraic RecordsCount | FeatureCount models); sweep reduced to storage-bytes sampling; ADMIN verifier POST /stats/verify (report/repair drift)

Zero wire-contract changes — resolution semantics, page boundaries, cursors, sort behaviour (including nullable-column NULLS LAST), CSV dumps, and error codes are all pinned by contract/integration tests through the rebuild.

Migrations: two incremental revisions on b47f9c2e8a31; upgrade-in-place proven on a DB seeded at the prior head (index built, old index dropped, data intact, backfill exact); alembic check zero drift; downgrade/upgrade round-trips cleanly. CREATE INDEX CONCURRENTLY runs in an autocommit block — no table lock on live archives.

Verification: 1560 unit + 59 contract + 211 integration tests green; ruff + ty check osa fully clean. New tripwires: statement-capture (zero count( on request paths, LIMIT present on bounded reads), EXPLAIN (no Sort node, rows ≈ limit+1), lockstep invariants (rows+stats commit/roll back together, redo convergence, fresh-node zeros), verifier corrupt-report-repair.

Remaining before closing #219: deploy to pockets.amacr.in and re-run the curl matrix from the issue; paste before/after (baseline re-confirmed today: manifest 13.4s).

Every table read resolved its column schema by constructing the full
SchemaManifest — COUNT(*) on records plus two aggregates per feature
table, ~8.5s per request at 213k records / 5.1M feature rows — then kept
only a column list. resolve_table now reads columns straight from the
schemas / feature_tables catalogs via two new DataCatalogReadStore
lookups; the manifest path is untouched for its real consumers.

Resolution semantics are pinned by contract tests (bare id → latest
version, reserved-name 404s, feature matched by name AND kind, unknown →
404 before bytes) and a statement-capture tripwire asserts zero count()
statements on the resolve path.

Part of #219 (phase 1 of 6).
QueryPlan carried a limit on every read that the store ignored — the
SELECT had no LIMIT, PG fully sorted the joined result (a sort is a
pipeline breaker), and take_page discarded the surplus in Python over a
server-side cursor. Pagination is now a discriminated union: BoundedPage
compiles LIMIT limit+1 into the statement and runs a plain execute()
(the server-side cursor is pure overhead at page sizes); FullStream —
the CSV/gzip dump path — keeps AsyncSession.stream() and its
disconnect-safe cursor teardown. Unbounded reads are opt-in by name at
every layer: the route derives ReadMode from the response format, and
take_page rejects FullStream plans outright.

Statement-capture tests pin LIMIT presence on bounded reads and its
absence on dumps; page-boundary and no-gaps/no-dupes pagination
behaviour is pinned unchanged; the dump-path streaming guarantees
(bounded heap, cursor release on disconnect) now name FullStream
explicitly.

Part of #219 (phase 2 of 6).
The planner matches sort orderings textually, and SortKey emitted DESC
NULLS LAST unconditionally — an ordering no default btree serves in
either scan direction — so every default-sort read carried a Sort node
that materialized the entire joined result before its first row. SortKey
now knows column nullability: NOT NULL sort columns (published_at, srn,
feature id) emit plain ASC/DESC, semantically identical when NULLs
cannot exist and textually matchable to a backward index scan; nullable
metadata columns keep explicit NULLS LAST in both directions.

The keyset cursor predicate follows the same split: all-NOT-NULL
same-direction keys compile to the row-value form
(published_at, srn) < (:s, :id), which PG collapses to a single index
range scan; the OR-form survives only where row-values are not
equivalent (nullable/mixed sorts).

New incremental migration f3a1c9d27e54 builds
records (schema_id, schema_version, published_at, srn) CONCURRENTLY in
an autocommit block (live archives) and drops the left-prefix-subsumed
idx_records_schema_id; idx_records_published_at stays for
count_this_month. Upgrade-in-place proven from b47f9c2e8a31 with seeded
data; alembic check zero-drift.

EXPLAIN integration tests pin: records default sort and cursor-follow
pages plan with no Sort node and ≈ limit+1 rows examined; the feature
default sort (id = PK) needs no new index at realistic scale with fresh
statistics — verified, not assumed. Cursor wire-compat pinned by tests
that mint pre-#219 tokens byte-for-byte and resume pagination through
the new predicate path; nullable-sort semantics (absent last, both
directions, NULL-boundary pagination) pinned unchanged.

Part of #219 (phase 3 of 6).
PostgresFeatureStore.insert_features was the only DML in the system on a
private engine connection — it committed independently of the caller's
session, so a stage could half-land (records rolled back, feature rows
durable) and transactional statistics maintenance (#219 phase 5) would
have been unprovable. The delete+insert now runs on the injected
session; the table object is built from the feature_tables catalog
exactly as the read path builds it — runtime reflection was the only
reason the raw connection existed. create_table (DDL) stays
engine-scoped per the sanctioned MetadataStore split.

The stale 'Checkpoint C: separate engine + FK' comment in the batch
workflow now states what remains true: the commit is a redo boundary.
Feature rows land atomically with batches_completed and mark_delivered
at the stage's scope-exit commit.

Integration tests pin the property that could not hold before: feature
rows commit and roll back WITH the session, including replace-by-record
redo. E2E and store tests updated to commit their seeding sessions.

Part of #219 (phase 4 of 6).
New table_statistics(schema_id, schema_version, table_name) holds row
counts and (for feature tables) records-covered counts as write-model
derived state: the writing adapter upserts the delta inside its own
transaction via one shared helper (ON CONFLICT … row_count + :delta), so
displayed counts always equal committed data — no sweep, no projection
lag, and (from phase 6) no COUNT(*) on any request path.

Writers:
- PostgresRecordRepository.save/save_many — delta = rows actually
  inserted (ON CONFLICT-aware), grouped per schema version.
- PostgresFeatureStore.insert_features — replace-by-record yields exact
  in-transaction deltas: rows = inserted − deleted, coverage +1 only on
  a record's first feature write. Schema identity is derived from the
  record row itself (PK lookup in-transaction) rather than threading a
  parameter through both workflows — attribution cannot drift from the
  data, and the deposition pipeline (which holds no convention at that
  point) needs no new plumbing.

records_covered is NULL on the records row by domain design: coverage is
a feature-table concept; for records it is definitionally row_count and
storing a duplicate invites drift.

Incremental migration a8c4e6f19b02 creates the table and backfills it
from COUNT(*) / COUNT(DISTINCT record_srn) group-bys — that backfill and
the admin verifier (phase 6) are the only sanctioned whole-table
counting from here on. Upgrade-in-place proven from b47f9c2e8a31 with
seeded data (backfill exact); alembic check zero-drift.

Integration tests pin the four invariants: rows+stats commit/roll back
together; redo nets zero delta and zero coverage; duplicate batches do
not double-count; a fresh table simply has no stats row.

Part of #219 (phase 5 of 6).
… only

Every count a surface shows now comes from the lockstep-maintained
table_statistics, modelled algebraically: RecordsCount | FeatureCount
make 'records row with a coverage value' unrepresentable, and
SchemaTableCounts gives absent-is-zero by construction. The manifest —
and through it the catalog, SKILL.md, and the MCP views — issues zero
count() statements (statement-capture pinned); a fresh schema renders
zeros.

The 5-minute instance sweep loses its per-feature-table COUNT(*) loop:
feature_rows = SUM over table_statistics; the loop keeps only
storage_bytes (pg_total_relation_size — the one fact PG must be polled
for). The StatisticsStore port, InstanceStats, and the GetStats query
move to the data domain where the read surface lives; the dashboard's
records total switches from a full-table COUNT to the stats SUM
(records_this_month stays live — an index-served month window, the one
sanctioned counting statement on a request path).

New ADMIN-gated verifier (POST /stats/verify): recomputes truth with the
backfill's query, reports drift as stored/actual TableCount pairs, and
overwrites only on repair=true — defence in depth, never load-bearing.
Statistics deltas are algebraic too: RecordsDelta | FeatureDelta with
covered bounded to [0,1] (per-record replace semantics; a batch-level
writer must widen the bound deliberately).

Dead code removed at the root: SchemaFeatureReader count methods,
RecordService.count and the repository count chain. seed_record mirrors
the production writers' stats bump so seeded tests count like published
data. The MCP contract fake gains the phase-1 column lookups (contract
suite now part of the per-phase gate).

Part of #219 (phase 6 of 6).
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

Code Coverage

Package Line Rate Complexity Health
. 83% 0
application 100% 0
application.api 100% 0
application.api.mcp 80% 0
application.api.mcp.tools 89% 0
application.api.rest 76% 0
application.api.v1 88% 0
application.api.v1.routes 68% 0
application.api.v1.routes.data 93% 0
application.api.v1.routes.data.serializers 99% 0
application.event 100% 0
application.workflow 95% 0
domain 100% 0
domain.auth 100% 0
domain.auth.command 90% 0
domain.auth.event 100% 0
domain.auth.model 93% 0
domain.auth.port 99% 0
domain.auth.query 93% 0
domain.auth.service 91% 0
domain.auth.util 100% 0
domain.auth.util.di 79% 0
domain.curation 100% 0
domain.curation.adapter 100% 0
domain.curation.command 100% 0
domain.curation.event 100% 0
domain.curation.model 100% 0
domain.curation.port 100% 0
domain.curation.query 100% 0
domain.curation.service 100% 0
domain.data 100% 0
domain.data.command 73% 0
domain.data.model 97% 0
domain.data.port 100% 0
domain.data.query 93% 0
domain.data.service 82% 0
domain.data.util 100% 0
domain.data.util.di 89% 0
domain.deposition 100% 0
domain.deposition.adapter 100% 0
domain.deposition.command 91% 0
domain.deposition.event 100% 0
domain.deposition.model 94% 0
domain.deposition.port 100% 0
domain.deposition.query 87% 0
domain.deposition.service 97% 0
domain.deposition.util.di 95% 0
domain.feature 100% 0
domain.feature.event 0% 0
domain.feature.model 100% 0
domain.feature.port 100% 0
domain.feature.service 97% 0
domain.feature.util 100% 0
domain.feature.util.di 100% 0
domain.ingest 100% 0
domain.ingest.command 100% 0
domain.ingest.event 100% 0
domain.ingest.model 100% 0
domain.ingest.port 100% 0
domain.ingest.query 100% 0
domain.ingest.service 89% 0
domain.metadata 100% 0
domain.metadata.event 100% 0
domain.metadata.handler 100% 0
domain.metadata.model 0% 0
domain.metadata.port 100% 0
domain.metadata.service 93% 0
domain.metadata.util 100% 0
domain.metadata.util.di 100% 0
domain.record 100% 0
domain.record.adapter 100% 0
domain.record.command 100% 0
domain.record.event 100% 0
domain.record.model 100% 0
domain.record.port 100% 0
domain.record.query 100% 0
domain.record.service 67% 0
domain.semantics 100% 0
domain.semantics.command 94% 0
domain.semantics.event 100% 0
domain.semantics.handler 100% 0
domain.semantics.model 100% 0
domain.semantics.port 100% 0
domain.semantics.query 90% 0
domain.semantics.service 100% 0
domain.semantics.util 100% 0
domain.semantics.util.di 93% 0
domain.shared 96% 0
domain.shared.authorization 86% 0
domain.shared.model 93% 0
domain.shared.port 100% 0
domain.validation 100% 0
domain.validation.adapter 100% 0
domain.validation.command 98% 0
domain.validation.event 100% 0
domain.validation.model 97% 0
domain.validation.port 100% 0
domain.validation.query 100% 0
domain.validation.service 91% 0
domain.validation.util.di 94% 0
infrastructure 80% 0
infrastructure.auth 56% 0
infrastructure.data 34% 0
infrastructure.event 78% 0
infrastructure.http 92% 0
infrastructure.ingest 86% 0
infrastructure.k8s 77% 0
infrastructure.messaging 100% 0
infrastructure.oci 55% 0
infrastructure.persistence 72% 0
infrastructure.persistence.adapter 79% 0
infrastructure.persistence.mappers 62% 0
infrastructure.persistence.repository 37% 0
infrastructure.s3 39% 0
infrastructure.storage 100% 0
infrastructure.telemetry 100% 0
sdk 100% 0
util 100% 0
util.di 71% 0
Summary 80% (10198 / 12748) 0

@greptile-apps

greptile-apps Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR replaces request-time table scans with bounded reads, index-backed pagination, and transactionally maintained statistics. The follow-up fix serializes statistics verification with lockstep writers so a concurrent additive update is applied after repair rather than overwritten.

  • Adds bounded page versus explicit full-stream query plans.
  • Adds composite read indexing and materialized per-table statistics migrations.
  • Moves record and feature statistics updates into their writing transactions.
  • Adds an admin drift verifier and repair path protected by an EXCLUSIVE statistics-table lock.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported concurrent-repair overwrite is fixed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
server/osa/infrastructure/data/postgres_statistics_store.py Adds lock-protected drift detection and repair; the prior concurrent-update overwrite is no longer reachable.
server/osa/infrastructure/persistence/statistics_upsert.py Maintains additive per-table statistics through the same sessions and transactions as counted writes.
server/osa/infrastructure/persistence/repository/record.py Updates record statistics in lockstep with newly inserted records, preserving idempotent conflict handling.
server/osa/infrastructure/persistence/feature_store.py Moves feature DML and corresponding statistics changes into the shared unit-of-work transaction.
server/migrations/versions/a8c4e6f19b02_table_statistics.py Creates and safely backfills per-schema table statistics using validated dynamic-table identifiers.
server/osa/domain/data/model/query_plan.py Separates bounded interactive pages from explicitly unbounded streaming plans.
server/osa/infrastructure/data/postgres_table_read_store.py Pushes bounded limits into SQL while retaining server-side cursors for full exports.

Sequence Diagram

sequenceDiagram
    participant V as Statistics verifier
    participant S as table_statistics
    participant D as Records/features
    participant W as Lockstep writer
    V->>S: LOCK TABLE ... EXCLUSIVE
    V->>D: Recompute truth
    W->>D: Write counted rows
    W->>S: Apply additive statistics delta
    Note over W,S: Waits while verifier holds lock
    V->>S: Replace stored counts when repairing
    V->>S: Commit and release lock
    W->>S: Apply delta to repaired base
    W->>D: Commit rows and statistics together
Loading

Reviews (3): Last reviewed commit: "chore: untrack graphify output cache and..." | Re-trigger Greptile

Comment on lines +147 to +148
truth = await self._recompute_truth()
await self.session.execute(sa.delete(table_statistics_table))

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.

P1 Repair overwrites concurrent statistics

If an ingestion transaction commits while repair_table_statistics is running, the repair can delete that writer's statistics increment and reinsert the earlier truth snapshot, causing manifests and node statistics to remain stale until another repair.

Knowledge Base Used: Persistence & Storage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d802c2a — and the finding understated the blast radius: because the lockstep increments are additive, a clobbered concurrent delta skews the base permanently (future increments stack on it), not just until the next repair.

The verifier now takes LOCK TABLE table_statistics IN EXCLUSIVE MODE before its truth read, held to commit. The lockstep discipline is what makes a single lock sufficient: every counted write bumps table_statistics in the same transaction as its data rows, so an in-flight writer blocks at the bump while its rows are still uncommitted (correctly absent from the truth read) and re-applies its delta on the repaired base after the verifier commits. EXCLUSIVE blocks writers only — manifest reads proceed. The report-only path takes the same lock so it can't show phantom drift from mid-read commits.

Regression test (test_repair_does_not_clobber_concurrent_lockstep_writes) interleaves a concurrent save_many into repair's truth-read window and asserts the increment survives — red before the fix (3 ≠ 4), green after.

Greptile P1 on #220: a lockstep write committing between the verifier's
truth read and its delete+reinsert was clobbered — and because
increments are additive, the lost delta skewed the base permanently,
not just until the next repair.

The verifier now takes LOCK TABLE table_statistics IN EXCLUSIVE MODE
before recomputing, held to commit. Lockstep is what makes one lock
sufficient: every counted write bumps table_statistics in its own
transaction, so an in-flight writer blocks at its bump while its data
rows are still uncommitted (correctly absent from the truth read) and
re-applies its delta on the repaired base afterwards. Writers only —
manifest reads proceed. The drift report takes the same lock so it
cannot show phantom drift from mid-read commits.

Regression test interleaves a concurrent save_many into repair's
truth-read window and asserts the increment survives.
The generated graphify AST cache under server/osa/graphify-out/ was
swept into 6d9f51f by a broad git add. Untracked here and ignored at
any depth so it cannot recur; add-then-remove cancels out of the PR
diff.
@rorybyrne
rorybyrne merged commit 1b0ef06 into main Aug 16, 2026
14 checks passed
@rorybyrne
rorybyrne deleted the 219-perf-data-read branch August 16, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: the /data read surface in milliseconds — kill per-request COUNT(*), unbounded sorts, and live-aggregate SKILL/manifest rendering

1 participant