Skip to content

fix(video): read only the camera's own notified captions in the dedup scan (part of #706) - #867

Merged
goruck merged 1 commit into
mainfrom
fix/camera-caption-namespace-prefix
Oct 26, 2026
Merged

goruck merged 1 commit into
mainfrom
fix/camera-caption-namespace-prefix

Conversation

@goruck

@goruck goruck commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Part of #706. #706 stays open: this fixes the recent-caption scan, and one of the issue's three readers (the similarity search) is unchanged by design. See below.

What was wrong

The langgraph Postgres store matches a namespace as a text prefix (prefix LIKE 'video_analysis.side%'), so a read for camera.side also returns camera.side_gate's captions. _ in a name is a LIKE wildcard as well. The recent-caption scan reads a camera's notified captions from the last 30 minutes, up to 200 rows in 4 pages. Rows from other cameras were dropped in Python only after the store applied its limit. So a busy side_gate could fill all 200 rows, side's own recent notifications were never read, and a repeated scene notified again.

Changes

  • Captions name their camera. Each stored caption gets a camera field, and the recent-caption scan filters on it in the database, before its page limit.
  • Background backfill (core/caption_store.py) adds the field to captions stored before it existed:
    • Each start checks for captions without the field. This also catches captions an older version writes after a downgrade, or from another install on the same database.
    • One run per database, using an advisory lock. A run that loses the lock checks again every minute until nothing is left.
    • It walks the primary key in batches of 2000 and pauses as long as each batch took. updated_at is never changed.
    • Statement and lock waits are bounded. Lock timeouts, cancels, deadlocks and serialization failures are retried.
    • Each attempt closes its connection unless it finished cleanly, so no pooled connection is left holding the lock or the timeouts.
    • The task is cancelled at unload, before the pool closes.
  • get_recent_camera_activity removed. It is the third reader Cameras whose names share a prefix read each other's stored captions #706 names, and nothing has called it since 1f68e4f.
  • Unrelated TODOS entry included on purpose: the Show a stalled model backend and fail fast instead of waiting out long timeouts #828 case where a frozen model server holds a turn past its budget. It was promised to the reporter, and this was the next PR.

What is not fixed (why #706 stays open)

The similarity search (_search_own_captions) is the same as on main: no database filter, a wide limit of 50, and the Python namespace filter. A camera with 50 or more closer matches whose name extends this one's can still push this camera's matches out. That gives no_match, the scan is never reached, and a static scene can notify again.

The two database-side fixes I measured both trade one problem for another. Measurements are from pg17 with pgvector 0.8.7, on 100k synthetic captions with 1024-dimension vectors and an HNSW index:

Variant Busy camera (45k) Mid-size camera (25k) Small camera (8k)
main (no filter) 45 ms, 7/10 own fast, partial 1–3 own
camera filter 617 ms (exact scan: no statistics on the jsonb expression) — —
filter + CREATE STATISTICS 15 ms, 10/10 0.9 s (exact scan) 1–3 own (HNSW post-filter, ef_search=40)
+ hnsw.iterative_scan — 0.9 s 10/10 rows

Prepared generic plans did not undo the statistics; timings stayed steady over 40 runs per connection. The search's fix belongs with #706 and #707. Measurements are posted on #706.

Testing

  • make lint passes and make typecheck reports 0 errors. make test: 6490 passed, 1 skipped.
  • test_caption_store.py covers:
    • batches walking the primary key and ending on a short batch
    • waiting while another run holds the lock, then taking over when it stops
    • retries, including on a deadlock
    • giving up after the retries
    • unlock failure
    • a cancel landing in a batch, during the pause, during the lock release, or while the lock is being granted
  • test_video_analyzer_novelty.py uses a fake store that matches namespaces the way LIKE does (_ as a wildcard) and applies the filter before the limit. Covered:
  • test_embedding_record.py: the backfill starts with a database, is cancelled at unload, and doesn't start without a database.
  • Real pg17 + pgvector, using throwaway containers:
    • 100k captions backfilled in 8.7 s, every batch walking store_pkey
    • a single run, two concurrent runs, and a cancel followed by a resume: every row has the right camera, updated_at is unchanged, unrelated namespaces are untouched, no advisory lock is left behind, and pooled connections are back on their default timeouts
  • Not field-tested on the box yet.

Review

gstack /review: 3 cycles, not converged, because cycle 3 still needed small fixes, all applied. Codex ran in every cycle, alongside the Claude adversarial pass, the red team, and the testing, maintainability, security, performance, data-migration and simplification reviewers. Main design changes during review:

  • dropped a permanent done-marker
  • switched to a keyset walk under an advisory lock
  • cancel on unload
  • tried planner statistics, then removed them
  • limited the camera filter to the scan (Lindo's call)

Ships in the 3.45.0 bundle.

🤖 Generated with Claude Code

https://claude.ai/code/session_019kuwyteUa5Q6rkkB8hgwwj

… scan

Part of #706.

The store matches a namespace as a text prefix, so the recent-caption scan
for camera.side also read camera.side_gate's notified captions, and its
200-row budget was spent before the database's rows reached Python's
namespace check: a busy prefix-sharing camera hid side's own recent
notifications, and a repeated scene notified again.

- Each stored caption now names its camera, and the recent-caption scan
  filters on it in the database, before its page limit.
- A background task adds the field to captions stored before it existed:
  a pending check each start, one run per database (advisory lock; a
  waiting run looks again every minute), a keyset walk in 2000-row batches
  that leaves updated_at alone, bounded statement/lock waits with retries,
  and a connection closed unless the attempt finished cleanly. It is
  cancelled at unload before the pool closes.
- The similarity search is unchanged: a camera filter there made Postgres
  score every caption of a busy camera (0.6 s at 45k rows), and planner
  statistics traded that for partial results. Its prefix crowd-out stays
  open in #706.
- get_recent_camera_activity had no caller since 1f68e4f; removed.
- TODOS: the #828 frozen-server hang past the turn budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kuwyteUa5Q6rkkB8hgwwj
@claude

claude Bot commented Oct 26, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@goruck
goruck merged commit 1f5e8ec into main Oct 26, 2026
11 checks passed
@goruck
goruck deleted the fix/camera-caption-namespace-prefix branch October 26, 2026 19:33
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.

1 participant