Skip to content

feat(memory): track access_count and last_accessed_at on reads - #53

Merged
ScottRBK merged 4 commits into
ScottRBK:mainfrom
bertheto:feat/upstream-memory-usage-tracking
Aug 9, 2026
Merged

ScottRBK merged 4 commits into
ScottRBK:mainfrom
bertheto:feat/upstream-memory-usage-tracking

Conversation

@bertheto

@bertheto bertheto commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds read-side usage telemetry for memories: access_count and last_accessed_at columns, repository record_memory_access, and best-effort access updates via EventBus subscribers on memory.read / memory.queried.

Motivation

Maintainer feedback on #45: time-based decay is not the right product direction for upstream. This PR keeps only the observability layer (how often memories are actually returned on read paths) without any clock-based GC, confidence decay, or run_decay_scan tool.

Scope

  • Alembic migration 20260704_add_memory_usage_tracking
  • ORM columns + index on SQLite and Postgres
  • record_memory_access in both repositories + protocol
  • Pydantic Memory.access_count / last_accessed_at (read-only; not on MemoryUpdate)
  • EventBus subscribers (handle_memory_access_event) wired in bootstrap + e2e lifespan
  • query_memory / get_memory emit memory.queried / memory.read when ACTIVITY_TRACK_READS=true; access counters update in the subscriber
  • updated_at is never mutated by access tracking

Opt-in gates

access_count / last_accessed_at update only when both are true:

  • ACTIVITY_ENABLED=true (event bus exists)
  • ACTIVITY_TRACK_READS=true (default false)

Delivery is best-effort async via EventBus; handler failures are logged and do not fail the read path.

Out of scope

  • No run_decay_scan, no decay formula, no get_decay_candidates
  • No contradiction detection / supersession redesign

Refs #45

Test plan

  • uv run pytest tests/integration/test_memory_usage_tracking.py -q (4 tests: repo direct, get_memory EventBus, query_memory primaries+linked, TRACK_READS=false negative)
  • uv run ruff check on touched files
  • No remaining inline record_memory_access in query_memory / get_memory

Additive usage telemetry only. No time-based decay or run_decay_scan.
Responds to maintainer feedback on PR ScottRBK#45.
@ScottRBK

ScottRBK commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Thanks for this — keeping it scoped to pure observability (no decay/GC) per the #45 feedback is the right call, and the atomic access_count + 1 update / untouched updated_at are both handled correctly. A couple of things worth addressing before merge:

1. This should reuse the existing EventBus + ACTIVITY_TRACK_READS machinery instead of adding a new unconditional write path

Right now record_memory_access is called unconditionally and synchronously (inline await, wrapped in try/except) from both query_memory and get_memory in memory_service.py. Two issues with that:

  • This codebase already has a convention for exactly this problem — document_service.py, skill_service.py, and file_service.py all gate read/query event emission behind settings.ACTIVITY_TRACK_READS (default False) specifically to avoid write-amplification on hot read paths. This PR bypasses that convention: the new write runs on every single query_memory/get_memory call, with no way to turn it off.
  • memory_service.py already emits a memory.read event (on get_memory) and a memory.queried event (on query_memory) — both gated by ACTIVITY_TRACK_READS, both already carrying the exact IDs needed (entity_id for read; result_ids/linked_ids for queried).

Given that, usage tracking fits naturally as an EventBus subscriber instead of inline calls:

event_bus.subscribe("memory.read", handle_access)
event_bus.subscribe("memory.queried", handle_access)

This gets you three things for free that the current implementation doesn't have:

  • Gating comes for free — the events only fire when ACTIVITY_TRACK_READS is on, so no separate flag check is needed.
  • Genuinely non-blocking — EventBus.emit() dispatches via asyncio.create_task with built-in per-handler error isolation (_safe_dispatch), unlike the current synchronous await + try/except which still blocks the response while the write happens.
  • No duplicated ID-extraction logic between the two call sites in the service layer.

One trade-off to flag explicitly if you go this route: event bus delivery is in-process and best-effort (a handler exception or a mid-dispatch process death means the count is silently missed). Since this PR already frames the feature as best-effort telemetry, that seems like an acceptable fit — just worth being a deliberate choice rather than a surprise.

2. No test coverage for the query_memory hook

tests/integration/test_memory_usage_tracking.py covers record_memory_access directly and via get_memory, but query_memory — which tracks two separate ID lists (final_primaries + final_linked) — has no coverage at all. Worth adding a case there too (and it'll need to move to cover the event-subscriber path if #1 lands).

@ScottRBK

ScottRBK commented Aug 8, 2026

Copy link
Copy Markdown
Owner

@bertheto hope the above makes sense - if not hit me up on discord and we can discuss further. This is a really nice feature so i am keen to add it one way or another :)

@bertheto

bertheto commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I'll move access tracking onto EventBus subscribers for memory.read / memory.queried so it reuses ACTIVITY_TRACK_READS and stays non-blocking. Best-effort delivery is fine for this.

I'll also add a query_memory integration case for primary + linked IDs on that path.

Note: with ACTIVITY_TRACK_READS defaulting to false, access_count stays at zero unless the flag is on. Same convention as the other read-activity events. I'll mention that in the PR description.

Pushing a follow-up shortly. Ping me on Discord if anything looks off.

Scott's PR ScottRBK#53 review: record access via memory.read / memory.queried
handlers instead of inline writes. Add query_memory integration coverage
and ACTIVITY_TRACK_READS=false negative test.
Impl-review follow-ups: register_access_tracking_handlers helper,
assert updated_at invariants, verify QUERIED snapshot IDs for query path.
@bertheto

bertheto commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the follow-up (e9eb7ef + 456eeb2). Access tracking now runs through EventBus subscribers on memory.read and memory.queried instead of inline writes. query_memory test is in, PR description covers the dual gates.

REST e2e for GET/search access_count with ACTIVITY_TRACK_READS on,
plus negative gate when tracking disabled.
@ScottRBK

ScottRBK commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Confirmed — access tracking now runs entirely through the memory.read/memory.queried EventBus subscribers registered in bootstrap.py, so it's gated by ACTIVITY_TRACK_READS for free and no longer blocks the response. query_memory's primary+linked ID case is covered, and the disabled-flag e2e test confirms access_count stays at zero when the flag is off. This is good to merge from my side — nice work threading this through the existing architecture rather than bolting on a parallel path.

@ScottRBK
ScottRBK merged commit 0cbf1e1 into ScottRBK:main Aug 9, 2026
2 checks passed
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.

2 participants