Skip to content

perf(recall): one prepare_context issues 178 SQL statements; batch the Atomic Memory authority filter and pin a statement ceiling #2075

Closed

Description

@Inference1

Summary

Measured on master at b388a18, one prepare_context call over a single Scope issues 178 SQL
statements
once 16 Atomic Memories exist, and 94% of them are the same query shape re-issued once
per candidate
. The batched repository primitives already exist and are already used earlier in the
same request; only the authority filter stage is per-item.

I am not proposing any DDL change. The 66 declared tables are contracted by tests (see "Why the table
count is not the lever"). The read amplification per request is not.

On embedded SQLite this currently costs ~36 ms and is therefore invisible; on a networked backend the
same 178 statements are 178 round trips, which is the budget application.py:362-364 is already
trying to leave room for. Numbers below.

Measurements

Setup, no confounders: Python 3.12, pip install -e ".[builtin,server]", in-memory SQLite
(SQLiteConfig()), no embedding model configured, no reranker configured, one Scope, seeded
with runtime.atomic_memory.for_scope(scope).create(...), then one
runtime.context.for_scope(scope).prepare(PrepareContextRequest(query="contract gateway")). Seeding
statements are excluded. Counting uses event.listen(Engine, "before_cursor_execute", ...) — the same
harness as tests/e2e/test_sqlite_vec.py:59 and tests/builtin/runtime/test_statistics.py:556.

memories statements distinct tables prepared.content_bytes
1 53 16 594
2 64 16 824
4 86 16 1284
8 130 16 2204
16 178 16 2206
24 178 16 2209
40 178 16 2209

Two things follow from the shape of that curve:

  1. Marginal cost is ~11 statements per additional memory up to 8, ~6 from 8 to 16, then 0 — the
    candidate pool ceiling (memory_candidate_limit = 16, runtime/prepared_context.py:147).
  2. From 8 memories onward the output stops growing (2204 → 2209 bytes) while the statement count
    keeps climbing to 178. Those additional reads do not change what the caller receives.

Statement shapes at 16 memories: 178 statements, 25 distinct shapes, 168 of 178 inside a shape issued
more than once
.

count shape
25x SELECT pc_artifacts.scope_id, … pc_artifacts.content, … (whole payload, one row at a time)
24x SELECT pc_artifact_lineage_sources.… WHERE … artifact_id = ?
24x SELECT pc_artifact_lineage_artifacts.…
24x SELECT pc_artifact_publications.…
17x SELECT pc_artifact_heads.<whole row>
16x SELECT pc_artifact_heads.revision WHERE … artifact_id = ?
8x SELECT pc_sources.… (one row at a time)
6x each pc_scopes, pc_scope_context_references, pc_scope_external_references
2x SELECT pc_artifact_heads.artifact_id … LIMIT ? — executed, rows discarded
3x / 3x SAVEPOINT sa_savepoint_1 / RELEASE SAVEPOINT sa_savepoint_1

Why this is a backend-portability problem rather than a today-is-slow problem

Same path, same 178 statements, measured at 12 repetitions after 3 warm-ups:

per-statement cost backend model prepare wall time (mean)
0 ms, in-memory embedded SQLite (default) 36.5 ms (SQL execution inside it: 9.8 ms)
0 ms, file-backed local WAL SQLite 33.3 ms
0.5 ms a nearby networked engine 279 ms
2 ms a networked engine 665 ms

The per-statement delay column is an injected model, not a measured OceanBase or seekdb run — I do not
have those numbers, and I am labeling it accordingly. What is measured is the shape: local cost is
concentrated in statement count rather than in execution or disk IO (9.8 ms of 36.5 ms, and file-backed
is not slower than in-memory). A backend where each statement is a round trip converts that statement
count almost 1:1 into wall time.

That matters because the project already budgets for it in the opposite direction: application.py:362
comments "Leave room for database reads and assembly within the default one-second Hook request" and
then sets _CONTEXT_MEMORY_EMBEDDING_TIMEOUT_SECONDS = 0.25. With a remote engine at 2 ms per statement,
the reads alone consume ~356 ms of that one second before assembly starts. seekdb and OceanBase are
supported backends, and #2031 is provisioning dedicated databases for them, so this is the intended
deployment and not a hypothetical.

Cause

  • _filter_current_candidates (builtin/runtime/atomic_memory.py:673) loops the candidate tuple
    (:694) and calls _current_record (:383) per item, which issues single-item artifacts.latest(...)
    (persistence/artifacts.py:461) plus service.states.get(...). That is the 25x/24x/24x/24x/17x/16x
    above.
  • The batched equivalents already exist and already run on the same request: _validate_search_hits
    (atomic_memory.py:551) calls artifacts.get_many (:605; persistence/artifacts.py:414, whose own
    docstring reads "Load exact Revisions and ordered lineage with bounded batch queries") and
    _load_lineage_many (artifacts.py:706). So this is wiring an existing owner into a second stage, not
    new machinery.
  • The whole filter runs twice per request whenever a reranker is configured: once from
    application.py:1171 (_available_atomic_candidates) and again from atomic_memory.py:729 inside
    _rerank. No reranker was configured in my runs, so 178 is the floor, not the worst case.
  • The snapshot pin executes and discards its rows:
    await connection.execute(select(ARTIFACT_HEADS_TABLE.c.artifact_id).where(scope_id).limit(1)) at
    atomic_memory.py:688-692; same pattern at :446-450 and runtime/atomic_memory_processing.py:601.
  • Scope resolution re-reads the same three Scope tables 6 times each within one request
    (scope/repository.py:134-182). runtime/_scope_cache.py holds leases, locks and counts only — it does
    not cache descriptors.

Proposal

  1. Batch the authority filter over the candidate refs, mirroring _validate_search_hits. This should
    collapse the 25x/24x/24x/24x groups to one statement each.
    On the constraint RFC 1803 already states for this ("Batch content completion adds a read and must use
    the same versions and consistent state as retrieval", docs/en/rfcs/1803-artifact-search-projections.md
    Drawbacks): the filter already runs inside application.database.transaction(consistent_snapshot=True)
    (atomic_memory.py:685-686), so the batched version can reuse that same connection and that same pin.
    The batched call would read exact hit.artifact_ref revisions, and the existing per-item equality
    checks (current.ref == candidate.hit.artifact_ref, state_version match, ACTIVE) would be applied
    to the batched rows unchanged. I read this as a mechanical translation, but it is your call whether the
    invariant should be restated rather than assumed.
  2. Make the second authorization pass one batched re-check instead of 6 × candidates. The comment at
    atomic_memory.py:728 — "Reauthorize the exact candidate bodies immediately before an external rank
    model" — is a real security boundary, so I am not proposing to delete the second pass, only to
    batch it. I would want a maintainer view on whether the per-item form buys an ordering guarantee that
    a batched form would lose; if it does, this item is dropped.
  3. Drop the discarded pin selects, or replace them with a snapshot handle the transaction helper can
    already expose, if one exists.
  4. Add a statement-count ceiling for prepare_context. Nothing pins this today, so the number can only
    grow. Precedents in-repo: tests/builtin/runtime/test_statistics.py:553 asserts each budgeted table is
    read exactly once regardless of Scope count, with the docstring "A selection must not cost one query per
    Scope; see issue bug(server): POST /v1/stats latency degrades sharply under low concurrency on file-backed SQLite #1599"; and tests/e2e/test_sqlite_vec.py:117 asserts < 40.

No new table, no column, no migration, no new HTTP/MCP operation, no public contract change.

Why the table count is not the lever

The repo declares 66 tables (52 of them in builtin/persistence/tables.py, listed as
BUILTIN_TABLES:1334). Reducing that number is deliberately blocked:

  • tests/builtin/persistence/test_mysql_schema.py:152-169 parses the --table '…' lists out of
    docs/en/docs/operate/troubleshoot.md and docs/zh/…, requires en == zh, requires 4 restore layers,
    requires every FK parent in a strictly earlier layer, and asserts
    set(restored) == {t.name for t in BUILTIN_TABLES}. Renaming, merging or dropping one table fails on
    both the code and the docs side.
  • :171-189 pins max(index key budget) == 2640; merging tables widens composite keys toward that ceiling.
  • The apparent "head + version" duplication is not duplication: pc_artifact_heads carries a lifecycle
    CHECK machine (tables.py:250-266), pc_candidate_heads a discriminated-union CHECK (:434-450), and
    the Topic Memory projections are reconciled against their publications at startup, raising
    TopicMemoryStorageInvariantError on drift (persistence/topic_memory.py:105-215).
  • The 4 extra SQLite tables are engine-required FTS5/vector helpers; OceanBase expresses the same thing as
    columns and indexes on the shared tables.

The genuine storage-side duplication is real but already adjudicated: one body is stored as
pc_artifacts.content (:199), then pc_artifact_heads.searchable_text (:234), then mirrored into FTS;
Topic Memory stores a body three times; RFC 1803's Drawbacks already concede "Wide tables may duplicate
content". Not proposed here — listed so it is visible that I looked and found it already decided.

What I did not measure

Related

#1599 (fixed doctrine, statistics path) · #1631 (merged N+1 fix, SQLite vector completeness) · #1864
(rejects "just make prepare_context cheaper" as a decision-cost optimization — this issue does not
disagree; it is about statements per admitted entry, and batching is the cheapest way to serve that same
manifest) · #1866 (its rejected alternative is "do not scan inside recall, recall is the hot path" —
agreement, not conflict) · #2039 / #2040 (timeout and token chain, no overlap; the budget arithmetic above
is offered as context, not as a claim about their findings) · #1937 / #2005 (optional rerankers, which would
make proposal 2 load-bearing) · #2031 (dedicated seekdb/OceanBase databases, which is the deployment where
the round-trip cost lands).

Activity

  1. moved this from Todo to In progress in PowerContexton Oct 19, 2026
  2. Inference1 commented on Oct 20, 2026

    @Inference1
    ContributorAuthor

    Update: the count moved again, and embedded seekdb does not pay the round-trip cost

    Two things I can measure now that I could not when this was written.

    1. The recall path got much cheaper, so the number in the summary is stale. #2060 and #2095 together
    took one prepare_context from 178 statements to 40 (SQLite, 40 Atomic Memories; 19 for a single-entry
    Scope). A 41-statement ceiling now pins it (tests/builtin/runtime/test_statistics.py, added in #2087 and
    tightened to the current measurement in #2133). The 178 → 47 → 40 sequence is all CI-measured with the
    same before_cursor_execute counter used here.

    2. Embedded seekdb does not make each statement a round trip. Running the identical counter on the
    embedded profile (pylibseekdb 1.4.0.post1, SeekDBConfig), one Scope per corpus size:

    memories statements wall (single run)
    1 23 18.7 ms
    8 44 16.1 ms
    16 44 16.6 ms
    40 44 16.3 ms

    Repeat at 40 memories, 5 runs: median 18.9 ms, min 17.6, max 19.6. That is roughly 0.4 ms per statement,
    and it is faster than the in-memory SQLite path (40 statements, ~33 ms).

    So the sentence "on a networked backend the same statements are round trips" is not supported by what I
    can actually run
    . Embedded seekdb is local. The argument may still hold for a remote OceanBase server,
    where each statement really is a network round trip — but I have not measured that, and I should not have
    presented it as the reason this matters.

    What survives the correction is backend-independent: the statement counts themselves are a stable,
    comparable metric (40 SQLite / 44 seekdb at saturation, flat in corpus size), and the per-reference growth
    described in #2088 — 17 statements per artifact evidence reference, 550 at the 32-reference bound — is
    still there and is not a backend artifact.

    If the direction is worth pursuing, the measured justification is now "fewer statements on a path that
    runs per item", not "this is slow today".

  3. moved this from In progress to Done in PowerContexton Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions