Skip to content

perf(core): per-note read-cache eviction groups on fastapi-redis-sdk, QUERY-only search and resolve - #1752

Merged
phernandez merged 11 commits into
mainfrom
read-cache-scopes
Oct 11, 2026
Merged

phernandez merged 11 commits into
mainfrom
read-cache-scopes

Conversation

@phernandez

Copy link
Copy Markdown
Member

Closes #1747. Builds the read cache the way #1747 proposes, on fastapi-redis-sdk.

Why

In production the read cache almost never hit: notes 1.4%, search 0.1%, resolve 22%.

  • Every write, and again every index completion, cleared every cached read in the project.
  • The TTL was 5 minutes.
  • Lookups ran inside route handlers, after project validation had already queried the database, so even a hit woke a scaled-to-zero tenant database.

What changed

One cache layer, before routing. Cached routes use a cached_read(...) route dependency on top of fastapi-redis-sdk, pinned at 0.8.1.

  • The SDK does the HTTP work: hit short-circuit, response capture, ETag/304, Cache-Control and the X-Redis-Cache header.
  • Route-level dependencies resolve before endpoint parameters, so a hit returns before any database access.

Versioned eviction groups instead of a project-wide generation. Each key embeds the current version token of its group, plus the project epoch and a global epoch.

  • Invalidating a group replaces its token, so there is no key scan.
  • A read that races a write stores under a token no later lookup uses, which makes the cache race-safe by construction.
Cached read Group
GET knowledge/entities/{id} that note
search, directory/tree, directory/list, memory/recent content
knowledge/resolve, directory/structure project epoch

Writers record exactly what they changed (ReadCacheChange, invalidate_recorded_change).

Change Retires
Body edit that note, plus link targets rendered from their entity row, plus content
Create, rename, move, delete, permalink change whole project
Materialization status that note
Vectors content
Index run from FileIndexResult (identity change → project; otherwise note + content)
Relation resolution resolved sources and their entity-rendered targets; nothing if nothing resolved
Any failure after a possible commit whole project

Keys include the user (supplied by the host through app.state.read_cache_scope_resolver), the workspace context, the path and query, and the canonical QUERY body.

TTLs only bound memory now: 7 days for notes, resolve and listings; 24 h for search; 15 min for recent activity, which is relative to now.

QUERY only. Project search, scoped search, knowledge/resolve and knowledge/links/resolve drop their POST routes, with no compatibility shim. Core clients use QUERY.

bm cache reset replaces the global epoch, for after an upgrade that changes responses.

Notes for review

  • The SDK is now a required dependency. It imports redis at import time and route dependencies are declared at import time. redis-py is pure Python.
  • Resources are not cached. SDK 0.8.1 replays hits as application/json with no response headers; header replay and the RFC 9111 store rules are on its main but unreleased. Resources can be cached again after the next SDK release.
  • Fuzzy resolutions are never stored. The route drops the pending store, because a fuzzy match can change with any note's content.
  • Republishing a note still un-resolves its links until the resolve pass runs. That's existing behavior, and the resolve pass retires the affected notes.

Testing

  • test-int/read_cache/test_cache_rules.py covers the rules end to end through the API against real Redis, using the X-Redis-Cache header:

    • miss then hit then 304
    • a body edit retires that note and content only, while other notes, resolve and folder structure stay cached
    • linking to a note leaves it cached
    • a rename retires the project
    • fuzzy resolve is not cached
    • users never share entries
    • reset retires everything
    • Redis down falls back to the database
    • POST search and resolve return 405
    • slices are separate entries
    • one span per read

    Load-bearing check: forcing body edits to clear the whole project makes the per-note test fail.

  • The older read-cache suites were migrated to version assertions (just test-read-cache: 69 passed). The benchmark now asserts a hit runs zero SQL.

  • New real-database test for note_ids_touched_by_resolution.

  • Targeted unit and API runs pass. just fast-check is clean apart from the pre-existing pymilvus import errors (the optional extra isn't installed locally).

🤖 Generated with Claude Code

https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv

phernandez and others added 3 commits October 11, 2026 14:28
Replace the per-route read-through cache and its single project
generation with fastapi-redis-sdk route caching keyed by versioned
eviction groups (one per note, one for content, a project epoch, and a
global epoch for resets). Writers record exactly what they changed, so a
body edit retires that note and content-wide reads only.

Search, resolve and links/resolve are QUERY only. Adds bm cache reset.

WIP: older read-cache test suites still target the removed API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>
Rewrite the read-cache suites against the fastapi-redis-sdk design:
version tokens per note, content and project epoch instead of one
project generation, and route caching observed through X-Redis-Cache.

- test_redis_read_cache: version store (scoped vs whole-project bumps,
  tenant/project isolation, reset_all, TTLs, recorder scope, shielded
  cancellation, Redis unavailable and capacity failures).
- test_cache_rules: add slice-per-entry and one-span-per-read rules.
- API, runtime, materialization, MCP, search and benchmark suites read
  versions instead of generation keys; body edits assert per-note scope;
  the benchmark asserts a hit runs zero SQL.
- Resources are no longer cached; their tests assert that.
- Search, resolve and links/resolve calls in tests use QUERY.
- Fakes implement invalidate(project_id, change) and return
  RelationPublishOutcome.

src: RelationGenerationPublisher validates relations before the
pre-publication target read (fail before I/O).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>
ResolveRelationsResult carries the source entity ids a pass resolved.
resolved_relations_read_cache_change maps them to the notes whose
response changed (the sources, and resolved targets rendered from their
entity row), so the local resolver and hosted resolve jobs stop
clearing the whole project. A pass that resolves nothing retires nothing.

Drop POST /v2/search/; scoped search is QUERY only, like project search
and resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T23:00:40.889923Z df5ea41 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4782c12e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +76 to +80
cached_read(
ReadCacheOperation.search,
group=content_group,
ttl_seconds=SEARCH_READ_CACHE_TTL_SECONDS,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve Accept-Query on cached search hits

When Redis caching is enabled, the second equivalent QUERY request hits this dependency and raises CacheHitException before the endpoint executes, so response.headers["Accept-Query"] = "application/json" at line 112 never runs. Since fastapi-redis-sdk 0.8.1 does not replay the original response headers, cache hits violate the search response contract and omit Accept-Query; the integration test currently checks this header only on the initial miss. Add the header in the cache-hit response path or avoid caching this route until headers can be preserved.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3db3d40. cached_read now takes hit_headers, which the hit path adds to every cached response (the route never runs on a hit and SDK 0.8.1 doesn't replay stored headers). Search passes Accept-Query: application/json. test_search_read_cache now asserts the header on the HIT response too; it fails with the header line removed and passes with it.

@phernandez phernandez changed the title Read cache: per-note eviction groups on fastapi-redis-sdk, QUERY-only search and resolve perf(core): per-note read-cache eviction groups on fastapi-redis-sdk, QUERY-only search and resolve Oct 11, 2026
A hit returns before the search route runs, and fastapi-redis-sdk 0.8.1
does not replay stored headers, so hits dropped Accept-Query.
cached_read takes hit_headers and adds them to every cached response;
search passes Accept-Query. The search cache test now checks the hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3db3d40134

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

try:
# Raises the SDK's CacheHitException on a hit; its handler turns
# it into the cached response before the endpoint runs.
await anext(steps)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Invalidate cached reads when deleting projects

When Redis caching is enabled, warm an entity response and then delete its non-default project with delete_notes=true; this cache-hit path raises before ProjectExternalIdPathDep can verify that the project still exists, while delete_project_by_id removes the project without advancing its cache epoch. The same entity URL can therefore continue returning the deleted note content for the seven-day read TTL instead of returning 404. Project deletion must invalidate the project epoch, or project existence and access must be checked before serving a hit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e711c6f. DELETE /v2/projects/{id} now retires the project epoch around remove_project (also when removal fails partway, since what it committed is unknown). New rule test test_deleting_a_project_retires_its_cached_reads warms a note in a second project, deletes the project, and asserts the same URL returns 404 and not a HIT; it fails with the invalidation removed.

Comment thread src/basic_memory/read_cache/routes.py Outdated
Comment on lines +227 to +229
query = "&".join(
f"{name}={value}" for name, value in sorted(request.query_params.multi_items())
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Build cache keys from structured, declared query values

Flattening decoded query pairs without escaping creates cache-key collisions: /directory/list?dir_name=foo%26page%3D2 and /directory/list?dir_name=foo&page=2 both produce dir_name=foo&page=2, although one lists the literal directory foo&page=2 on page 1 and the other lists foo on page 2. Whichever response is cached first is then served for the other request. Including every query parameter also lets ignored parameters such as unique nonce values create unlimited duplicate entries retained for a week; serialize the declared response-shaping parameters structurally rather than concatenating all pairs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e711c6f. The key now includes only the matched route's declared query parameters (from FastAPI's flattened dependant, so it can't drift from the signature), serialized as JSON together with the path, canonical QUERY body and workspace context, so an encoded & can't collide with a real parameter and undeclared parameters like a nonce don't split entries. New rule test test_query_values_are_keyed_structurally_and_only_when_declared covers both; it fails with the old concatenation.

A hit is served before a route checks that its project exists, so
deleting a project must retire its cached reads: the delete route now
invalidates the project epoch, also when removal fails partway.

Cache keys took every query pair and joined them unescaped, so an
encoded & inside a value collided with a real second parameter, and
undeclared parameters (a nonce) split entries. Keys now hold only the
route's declared query parameters, serialized as JSON with the path,
QUERY body and workspace context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e711c6f10c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)

sdk_dependency = cache(ttl=ttl_seconds, key_builder=key_builder, cache_prefix="")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore a response-size limit before caching

When an entity, search result, or directory tree is large, this SDK dependency stores the entire serialized response because only a TTL is configured. This removes the previous 1 MiB general and 2 MiB directory payload limits; with the new one-to-seven-day TTLs, repeated slice/search variants or content invalidations can retain many oversized entries and exhaust Redis memory or evict useful hot entries. Add an equivalent capture-side size check or bypass caching for oversized responses.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 775c6f5 / adb463c: cached responses are capped at 2 MiB (READ_CACHE_MAX_PAYLOAD_BYTES). fastapi-redis-sdk reads MAX_CACHEABLE_BODY_SIZE from redis_fastapi.cache on every capture; the app now sets it at startup. Larger responses are still served, just not stored. New rule test test_oversized_responses_are_served_but_not_stored asserts the configured cap and, with a lowered cap, that an oversized note read never becomes a HIT. It fails with the setting removed.

phernandez and others added 2 commits October 11, 2026 15:44
Larger responses are served but not stored. fastapi-redis-sdk reads
MAX_CACHEABLE_BODY_SIZE (10 MiB default) from redis_fastapi.cache on every
capture; the package's re-exported cache() function shadows that submodule
on attribute access, so the module is fetched with importlib.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: adb463cda2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +200 to 203
invalidate_recorded_change(
self.read_cache,
self.project.project_external_id,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove the redundant project-wide watcher invalidation

On the local watcher path, every processed file has already replaced the entire project epoch at lines 180–184 before this new per-note relation invalidation runs. The runtime factory also wraps the file indexer in InvalidatingIndexFileExecutor, which has already retired the precise FileIndexResult.read_cache_change; consequently, every ordinary watched body edit still evicts unrelated notes, searches, resolves, and listings, defeating the per-note eviction design and its expected hit-rate improvement. Remove the broad completion invalidation and retain the narrow index and relation invalidations.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, fixed in e0e894f. The watcher's index_file_completed no longer clears the project; InvalidatingIndexFileExecutor already retires the file's precise change, and the relation pass retires only the notes it resolved. New test test_watcher_index_completion_leaves_unrelated_reads_cached asserts the project epoch is unchanged after a processed watched edit, and fails with the old clear restored. The cancelled-completion test now covers delete only (still whole project); the cancelled index commit stays covered by test_cancelled_watcher_index_commit_finishes_real_redis_invalidation.

The watcher's file indexer is wrapped in InvalidatingIndexFileExecutor, which
already retires exactly what the file changed. The extra project-wide clear on
completion evicted every unrelated cached read on each watched body edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0e894f17e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +597 to +600
invalidation_scope = (
invalidate_cache(read_cache, old_project.external_id)
if read_cache is not None
else nullcontext()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Invalidate standalone cache for CLI project deletes

When a configured standalone cache contains warm project reads, bm project remove --local executes this route through the CLI's lifespan-less ASGI app, whose fresh ApiContainer always has read_cache=None; consequently this invalidation scope is skipped. A running MCP process can then keep serving the deleted notes because cache hits short-circuit project validation, and even restarting does not repair it because _invalidate_persisted_read_cache() enumerates only active projects, excluding the deleted ID. The earlier route-level invalidation therefore does not cover this separate deletion entrypoint; connect local CLI mutations to the configured invalidator or reset/tombstone deleted-project epochs.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 4360615, both halves:

  • CLI deletes (and every other CLI write) now reach the cache. _prepared_local_asgi_database (the shared, ref-counted local ASGI setup) opens the configured standalone Redis cache and installs a container when no MCP/API host has installed one. The last client to exit closes it with the database state. test_cli_project_delete_retires_the_standalone_cache creates and deletes a project through _asgi_client and asserts the project epoch the MCP server reads has moved; it fails with '0.0' != '0.0' without the change.
  • Restart retires deleted projects. MCP startup now calls reset_all() (global epoch, in every key) instead of looping over active projects. test_mcp_startup_retires_entries_of_deleted_projects covers it.

…s deleted projects

A plain CLI process (bm project remove --local, bm tool writes) runs the API
through local ASGI without a lifespan, so its container had no cache and its
mutations retired nothing. A running MCP server, which serves hits before
project validation, then kept answering reads for a deleted project.

- The shared local ASGI preparation opens the configured standalone cache and
  installs a container when no MCP or API host has one; the last client to
  exit closes it with the database state.
- MCP startup advances the global epoch instead of retiring only active
  projects, so entries of projects deleted while no cache was open are retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4360615bb7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +180 to +182
# The file indexer is wrapped in InvalidatingIndexFileExecutor, which already
# retired exactly what this file changed; a project-wide clear here would
# evict every unrelated note on each watched edit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate search entries after watcher vector sync

When a watched file is indexed with embeddings enabled, the index executor retires the content group before index_file_completed, but this callback subsequently awaits sync_entity_vectors_batch without another invalidation. A concurrent VECTOR/HYBRID search can therefore fill the new content generation from the old or partially updated vector index, and that result remains reachable for the 24-hour search TTL after embedding completes. The fresh evidence beyond the earlier watcher finding is the retained vector sync later in this callback after the broad completion invalidation was removed; retire the content group after this sync, as LocalEntityVectorSyncScheduler already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c3f1fa5. The watcher now retires content reads after sync_entity_vectors_batch (whole project if it fails). While here, the API-path LocalEntityVectorSyncScheduler had the opposite problem: it cleared the whole project after every write's embedding. Both now record ReadCacheChange.search_index() (content group only), since embeddings change VECTOR/HYBRID results, not note responses. The watcher test gains an embeddings case asserting content moves and the project epoch does not; the scheduler unit test asserts the recorded change.

Comment thread src/basic_memory/read_cache/routes.py Outdated
Comment on lines +229 to +233
body = None
if request.method == "QUERY":
raw = await request.body()
if raw:
body = json.loads(raw)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Key QUERY bodies from validated response-shaping fields

Although the follow-up now filters undeclared query parameters, the QUERY body is still keyed from raw json.loads output. SearchQuery and EntityResolveRequest use Pydantic's default extra-field behavior, so bodies such as {"text":"x","nonce":"1"} and {"text":"x","nonce":"2"} execute the same search but create distinct retained entries; repeated ignored fields can therefore duplicate a large response until the 24-hour or seven-day TTL and exhaust Redis. Parse or project the body onto the route's declared body model before constructing the identity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c3f1fa5. _request_identity validates a QUERY body against the route's single body model (TypeAdapter, cached per model) and keys the JSON dump, so ignored fields, key order and spelled-out defaults share one entry; an invalid body bypasses the cache and gets the route's 422. test_query_bodies_are_keyed_by_their_validated_model sends two searches differing only by a nonce and order (second is a HIT) and fails without the change.

…r model

- Vector publication (API write path scheduler and watcher) retires content
  reads only: embeddings change VECTOR/HYBRID results, not note responses.
  The scheduler cleared the whole project after every write's embedding, and
  the watcher did not retire anything after embedding.
- A QUERY body is validated against the route's body model before keying, so
  ignored fields, key order and spelled-out defaults share one entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3f1fa519e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

request_digest=read_cache_request_digest(target.scope.user_key, target.identity),
)

sdk_dependency = cache(ttl=ttl_seconds, key_builder=key_builder, cache_prefix="")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent HTTP clients from retaining invalidated responses

When a browser or intermediary receives a cached GET /knowledge/entities/{id} or directory response, this SDK call emits Cache-Control: max-age=<ttl> by default—up to seven days here, as documented in the SDK caching guide. A later note mutation only advances Redis versions; it cannot invalidate the browser's still-fresh response, so the request never reaches this dependency and users can continue seeing old canonical note content for the full TTL. Override the emitted policy to require revalidation (and mark authenticated responses private) while retaining the long server-side Redis TTL.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining. No current consumer of these responses keeps an HTTP cache: the CLI and MCP use httpx (no HTTP cache), Cloud's web app goes through its own BFF endpoints that build fresh responses and don't forward core's caching headers, and raw /proxy and /bmc API-key clients are scripts and agents on httpx/requests. Fly's proxy doesn't cache and there is no CDN caching on API responses. If a browser or caching intermediary is ever put in front of the raw gateway, Cache-Control will be set at that edge.

pipe.set(
self._version_key(project_id, group),
uuid4().hex,
ex=READ_CACHE_TTL_SECONDS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep version tokens alive past racing cache fills

When a seven-day read obtains the initial 0 token immediately before an invalidation, reads the old note, and finishes after the invalidation, the SDK stores that response under 0 later than this replacement token was created. Exactly seven days after invalidation this version key expires back to 0, while the racing response remains alive until seven days after its later store, temporarily making stale canonical content reachable again. The version must not revert to a predecessor while any response captured under that predecessor can still exist.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

The CLI project-delete cache test failed only in CI: a container left
installed by an earlier test made the local ASGI client look like it ran
inside a host, so it did not open the standalone cache. An autouse fixture
clears the API container global per test, and the test asserts it starts
without a host container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N9c6ouiT4Wid3arGR41Dv
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df5ea41095

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +247 to +250
database_state = await resources.enter_async_context(
_resolve_local_asgi_database(app)
)
await _open_standalone_read_cache(resources)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retire projects deleted during config reconciliation

When a separate CLI process first opens after a project has been removed from local config, _resolve_local_asgi_database() runs reconcile_projects_with_config_once() and ProjectService.synchronize_projects() deletes the DB-only project before _open_standalone_read_cache() is installed. No epoch is advanced for the deleted external ID, so a concurrently running MCP server can continue serving a warmed entity response—now short-circuited before project validation—for up to seven days. Unlike the fixed explicit project-delete path, this reconciliation deletion remains untracked; have reconciliation return and retire removed project IDs, or reset the cache when it removes a project.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

@phernandez
phernandez merged commit 605a355 into main Oct 11, 2026
27 checks passed
@phernandez
phernandez deleted the read-cache-scopes branch October 11, 2026 23:20
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.

Read cache: invalidate per note instead of per project, and use long TTLs

1 participant