Skip to content

fix(capture): make session-event INSERTs idempotent to stop duplicate rows (C1)#324

Merged
efenocchi merged 5 commits into
mainfrom
fix/session-insert-idempotent
Jul 21, 2026
Merged

fix(capture): make session-event INSERTs idempotent to stop duplicate rows (C1)#324
efenocchi merged 5 commits into
mainfrom
fix/session-insert-idempotent

Conversation

@efenocchi

@efenocchi efenocchi commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What & why

Fixes C1 from the deeplake.ai ETL issue log: the sessions table was ~17% duplicate rows (249,294 rows / 206,292 distinct ids).

Root cause: session events were written with a plain INSERT ... VALUES, and the sessions table has no UNIQUE constraint on id. When the API retry loop re-sends an insert that actually committed but returned a transient 5xx (the 502/503 gateway-degradation window on 2026-07-10), a duplicate row is created. Timeouts already fail-fast, so the culprit is retryable 5xx re-sends.

Fix

Make every session-event INSERT idempotent so a re-send is a no-op. No dependency on a server-side UNIQUE constraint (which can't be applied to the existing table — it already contains duplicates — and risks CREATE INDEX oid errors on fresh tables).

  • Single-row capture path — new shared helper buildDirectSessionInsertSql (src/hooks/shared/session-insert-sql.ts) emits INSERT ... SELECT ... WHERE NOT EXISTS (id = ...). Wired into all direct-insert runtimes, removing the duplicated inline SQL: claude / codex / cursor / hermes capture.ts, codex/stop.ts, openclaw/index.ts, and pi (inlined — pi imports nothing from src/).
  • Batched pathbuildSessionInsertSql (src/hooks/session-queue.ts, used by the Cowork MCP ingest) switched to the multi-row anti-join: INSERT ... SELECT v.* FROM (VALUES ...) v WHERE NOT EXISTS (t.id = v.id).

Composes with the redactSecrets() masking already on main (C2): masking runs on line, which feeds the idempotent insert.

Verification (real backend, not just mocks)

Probed the real Deeplake backend on a sandbox table:

  • Rapid re-send inside the ~5s read-your-writes window → no duplicate (this is the exact retry-storm timing that caused the incident).
  • Re-send after 6s → still no duplicate.
  • Batched: 2-row batch re-sent → stays 2; partially-overlapping batch inserts only the new id.
  • A server-side UNIQUE INDEX is accepted+enforced now (so the "no server-side UNIQUE" note in graph/deeplake-push.ts is stale), but the WHERE NOT EXISTS guard is preferred — it works on the existing duplicated table with zero DDL risk.

Plus: real e2e driving the production SQL builder (5/5), source + bundle-guard tests, and updates to the existing tests that scraped the now-centralized column list. tsc clean, npm run build clean, full suite green (5577 tests).

Scope

C1 only. The redaction gaps codex flagged in codex/stop.ts, pi, and cowork-ingest.ts are C2 (secret masking) — a separate workstream; main's masking commit simply didn't cover those paths. Not addressed here.

Follow-up (not in this PR)

  • One-time cleanup of the ~43k existing duplicate rows in prod sessions (dedup by id).

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate session rows when capture inserts are retried or replayed by making session writes idempotent.
    • Ensured consistent session insert behavior across supported integrations, including correct reuse of the session/event ID and reliable embedding + metadata persistence.
  • Tests

    • Added and updated regression tests to verify the idempotent “insert-if-not-exists” SQL shape and to confirm required session columns (including message embeddings and plugin version) are present in shipped bundles.

…rows

Session events were written with a plain `INSERT ... VALUES` and the sessions
table has no UNIQUE constraint on `id`. When the API retry loop re-sends an
insert that already committed but returned a transient 5xx (502/503), a
duplicate row is created — the ~17% duplication observed in production during
the 2026-07-10 gateway-degradation window.

Route every capture INSERT through a shared `buildDirectSessionInsertSql`
helper that emits `INSERT ... SELECT ... WHERE NOT EXISTS (id = ...)`, making a
re-send a no-op. Verified lag-safe against the real backend: a rapid re-send
inside the ~5s read-your-writes window yields exactly one row. Applied across
all runtimes — claude/codex/cursor/hermes capture, codex stop, and openclaw —
via one helper, removing the duplicated inline SQL at the same time.

Tests: real e2e against the backend, source + bundle-guard tests, and updates
to the three existing tests that scraped the now-centralized column list.
Codex review of the previous commit flagged that the pi extension still wrote
session rows with a bare `INSERT ... VALUES` in writeSessionRow. pi has no
active 5xx-retry path today (dlQuery does not retry; writeSessionRow only
retries on table-not-visible), so it has no live duplicate vector — but the
bare insert is inconsistent with the fix and a latent bug if retry behavior
changes.

pi deliberately imports nothing from src/ ("raw .ts, zero deps"), so inline the
same `INSERT ... SELECT ... WHERE NOT EXISTS (id = ...)` shape rather than the
shared helper, kept in lockstep. Adds a pi-source regression guard.
…empotent

# Conflicts:
#	harnesses/openclaw/src/index.ts
Codex review found the batched session-queue insert path — used by the Cowork
MCP ingest (src/mcp/cowork-ingest.ts) — still emitted a bare multi-row
`INSERT ... VALUES`. That path builds a DeeplakeApi whose query() retries on
5xx (and re-sends again on table-missing), so it carried the same duplicate
vector as the per-event capture path.

Switch buildSessionInsertSql to the anti-join form:
  INSERT ... SELECT v.* FROM (VALUES ...) AS v(cols)
  WHERE NOT EXISTS (SELECT 1 FROM <table> t WHERE t.id = v.id)
so a re-sent batch skips rows already present. Verified against the real
backend: a 2-row batch re-sent inside and beyond the ~5s read-your-writes
window stays at 2 rows, and a partially-overlapping batch inserts only the
new id. Adds a source-level guard for the shape.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ad747e1-11e8-4fa2-9eaa-e23779c36aa0

📥 Commits

Reviewing files that changed from the base of the PR and between c49ccd8 and c556c2c.

📒 Files selected for processing (9)
  • harnesses/openclaw/src/index.ts
  • harnesses/pi/extension-source/hivemind.ts
  • src/hooks/capture.ts
  • src/hooks/codex/capture.ts
  • src/hooks/codex/stop.ts
  • src/hooks/cursor/capture.ts
  • src/hooks/hermes/capture.ts
  • tests/claude-code/plugin-version-resolution.test.ts
  • tests/claude-code/session-queue.test.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • tests/claude-code/session-queue.test.ts
  • src/hooks/cursor/capture.ts
  • src/hooks/hermes/capture.ts
  • src/hooks/codex/capture.ts
  • src/hooks/codex/stop.ts
  • tests/claude-code/plugin-version-resolution.test.ts
  • harnesses/pi/extension-source/hivemind.ts
  • src/hooks/capture.ts

📝 Walkthrough

Walkthrough

Changes

Session insert idempotency

Layer / File(s) Summary
Shared direct session INSERT flow
src/hooks/shared/session-insert-sql.ts, src/hooks/{capture,codex,cursor,hermes}/*, harnesses/openclaw/src/index.ts, tests/claude-code/*, tests/openclaw/*, tests/shared/*
Direct capture paths now use buildDirectSessionInsertSql, reuse event IDs, and emit INSERT ... SELECT ... WHERE NOT EXISTS SQL while preserving payload, embedding, metadata, and timestamp fields.
Queued batch deduplication
src/hooks/session-queue.ts, tests/claude-code/session-queue.test.ts
Batch inserts now exclude rows whose IDs already exist in the sessions table.
Pi session-row retry handling
harnesses/pi/extension-source/hivemind.ts, tests/pi/pi-extension-source.test.ts
Pi session writes compute one row ID and guard insertion with an ID-based NOT EXISTS check.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: ayush7614, kaghni

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making session-event inserts idempotent to prevent duplicate rows.
Description check ✅ Passed The description covers the problem, fix, verification, scope, and follow-up; it only omits an explicit version-bump/release note.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-insert-idempotent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Scope: files changed in this PR. Enforced threshold: 90% per metric (per file via vitest.config.ts).

Status Category Percentage Covered / Total
🟢 Lines 96.78% (🎯 90%) 511 / 528
🟢 Statements 94.00% (🎯 90%) 564 / 600
🟢 Functions 100.00% (🎯 90%) 62 / 62
🔴 Branches 85.37% (🎯 90%) 321 / 376
File Coverage — 7 files changed
File Stmts Branches Functions Lines
src/hooks/capture.ts 🟢 95.2% 🔴 82.7% 🟢 100.0% 🟢 100.0%
src/hooks/codex/capture.ts 🟢 97.2% 🔴 84.2% 🟢 100.0% 🟢 100.0%
src/hooks/codex/stop.ts 🟢 96.1% 🔴 83.3% 🟢 100.0% 🟢 98.5%
src/hooks/cursor/capture.ts 🟢 92.0% 🔴 88.3% 🟢 100.0% 🟢 95.9%
src/hooks/hermes/capture.ts 🔴 85.1% 🔴 82.5% 🟢 100.0% 🔴 87.2%
src/hooks/session-queue.ts 🟢 96.7% 🔴 87.8% 🟢 100.0% 🟢 98.3%
src/hooks/shared/session-insert-sql.ts 🟢 100.0% 🟢 100.0% 🟢 100.0% 🟢 100.0%

Generated for commit 14cea7b.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@harnesses/pi/extension-source/hivemind.ts`:
- Around line 972-975: Update the SQL construction around the session INSERT to
pass the agent value through sqlStr() before interpolating it, matching the
escaping used for other string parameters and preserving valid handling of
quotes and injection characters.

In `@src/hooks/capture.ts`:
- Around line 171-184: Use the JSON envelope ID as the database row ID instead
of generating a new UUID in the capture insert builders: update
src/hooks/capture.ts lines 171-184, src/hooks/codex/capture.ts lines 140-153,
and harnesses/openclaw/src/index.ts lines 1542-1555 to pass entry.id with the
appropriate typing. Apply the same change to the capture flows in
cursor/capture.ts, hermes/capture.ts, and codex/stop.ts, preserving each flow’s
existing insert behavior.

In `@src/hooks/cursor/capture.ts`:
- Around line 166-179: The capture hooks currently generate database row IDs
separately from the IDs in their JSON payloads. In src/hooks/cursor/capture.ts
lines 166-179 and src/hooks/hermes/capture.ts lines 149-162, update the id
passed to buildDirectSessionInsertSql to reuse entry!.id as a string; in
src/hooks/codex/stop.ts lines 134-147, reuse entry.id instead of generating a
new UUID.

In `@tests/claude-code/plugin-version-resolution.test.ts`:
- Around line 78-83: Update the sessionsInsert regex assertion to match the
complete expected column list from buildDirectSessionInsertSql, replacing the
generic [^`]*? wildcard with explicit column names and ordering while preserving
the plugin_version check.

In `@tests/claude-code/session-queue.test.ts`:
- Around line 112-123: Update the test "builds an idempotent batch insert
(anti-join, not a bare VALUES insert)" to replace broad regex assertions
containing .* with specific string-inclusion assertions for the expected SQL
structure. Keep validating the VALUES alias, the NOT EXISTS anti-join, and the
absence of the direct duplicate-prone VALUES form without relying on generic
substring patterns.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cbeca6d2-00a3-4d4a-8e3b-fd22f9f8e21f

📥 Commits

Reviewing files that changed from the base of the PR and between 410555c and c49ccd8.

📒 Files selected for processing (15)
  • harnesses/openclaw/src/index.ts
  • harnesses/pi/extension-source/hivemind.ts
  • src/hooks/capture.ts
  • src/hooks/codex/capture.ts
  • src/hooks/codex/stop.ts
  • src/hooks/cursor/capture.ts
  • src/hooks/hermes/capture.ts
  • src/hooks/session-queue.ts
  • src/hooks/shared/session-insert-sql.ts
  • tests/claude-code/plugin-version-resolution.test.ts
  • tests/claude-code/session-insert-sql.test.ts
  • tests/claude-code/session-queue.test.ts
  • tests/openclaw/openclaw-embed-bundle.test.ts
  • tests/pi/pi-extension-source.test.ts
  • tests/shared/agent-sessions-insert-schema.test.ts

Comment thread harnesses/pi/extension-source/hivemind.ts
Comment thread src/hooks/capture.ts
Comment thread src/hooks/cursor/capture.ts
Comment thread tests/claude-code/plugin-version-resolution.test.ts Outdated
Comment thread tests/claude-code/session-queue.test.ts
- Align the row id with the event id: every capture path now passes the
  message JSON's own `entry.id` as the row PK instead of a fresh random UUID,
  so the `id` column matches the id embedded in the `message` payload and stays
  the dedup key for the logical event. Applies to claude/codex/cursor/hermes
  capture, codex stop, openclaw, and pi (with a uuid fallback if a caller ever
  omits entry.id).
- pi: escape the `agent` value via sqlStr() in the inline INSERT, matching the
  shared helper and the other columns.
- tests: replace wildcard regexes with exact column-list assertions in
  plugin-version-resolution and session-queue tests.
@efenocchi

Copy link
Copy Markdown
Collaborator Author

Thanks @coderabbitai — all 5 addressed in c556c2c:

  • Row id ≠ payload id (Critical/Major) — every capture path (claude/codex/cursor/hermes capture, codex stop, openclaw, pi) now passes the message JSON's own entry.id as the row PK instead of a fresh crypto.randomUUID(), so the id column matches the id embedded in message and stays the dedup key for the logical event. pi keeps a uuid fallback if a caller ever omits entry.id.
  • pi agent unescaped (Critical) — now interpolated via sqlStr(agent), matching the shared helper and every other column.
  • Test assertions (Minor ×2) — replaced the wildcard regexes in plugin-version-resolution and session-queue tests with exact column-list assertions.

Note: the redaction gaps flagged elsewhere (codex/stop, pi, cowork-ingest) are out of scope for this PR — they belong to the separate C2 secret-masking workstream already on main. tsc + full suite (5577) green.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@efenocchi Thanks for the thorough update. The stable payload-derived row IDs, Pi escaping fix, and strengthened exact-column assertions address the five findings. Noted that the redaction work is already handled on main through the separate C2 workstream.

🐇✨


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@efenocchi
efenocchi merged commit 989dc9e into main Jul 21, 2026
11 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.

1 participant