Problem
captain_touch_session_activity is an AFTER INSERT OR UPDATE ... FOR EACH ROW trigger installed on eight tables including captain_messages (migrations/52_session_activity_triggers.sql:93-131). Its first statement is:
row_data jsonb := to_jsonb(NEW);
to_jsonb(NEW) serializes the entire tuple, including the parts JSONB blob, purely so the function can read session_id and occurred_at as text (:46-69). It then issues an UPDATE public.captain_sessions.
Ingesting a 10,000-message transcript executes 10,000 trigger functions and 10,000 full-row JSONB conversions. convergeMessages re-fires the trigger for every row its UPDATE ... IS DISTINCT FROM actually touches (pkg/database/session_message_ingest.go:75-99).
Physical session updates are fewer than trigger fires — the update is guarded on a newer timestamp (:73, :86) — so the cost is dominated by function invocation and tuple serialization rather than by row updates.
This issue is measurement-first
migrations/71_session_storage_params.sql:11 records the symptom:
7,791 live rows spread over 40,458 heap pages (316 MB) with just 8.8% of pages holding any live tuple — roughly 40x the ~10 MB the live set warrants. It was also the single largest source of physical reads in the database, at 1.47M of 1.76M total block reads and a 25% heap cache hit ratio while every other table sat above 92%.
But that migration attributes the churn broadly to heartbeats, state rewrites and triggers. Nothing in the tree isolates the to_jsonb cost, and the applied remedy — fillfactor = 70 plus autovacuum tuning — treats the bloat rather than whatever produces it.
Measure before changing anything. Capture per-ingest trigger cost and pg_stat_user_tables.n_tup_upd on captain_sessions for a representative transcript. SessionStorageStats (pkg/database/session_maintenance_store.go:110-145) already reports the bloat telemetry to compare against.
If measurement justifies a change
The rewrite must be per-table statement triggers using transition tables, not one generic drop-in:
- Statement-level triggers have no scalar
NEW; they must consume a REFERENCING NEW TABLE AS transition relation.
- INSERT and UPDATE need separate trigger arrangements.
- The current function considers
occurred_at, state_observed_at, started_at and resolved_at (:64-69). Aggregating MAX(occurred_at) alone silently changes behaviour for turns, model calls, prompt runs and turn requests.
- The
CASE TG_TABLE_NAME indirection (:46-59) exists because captain_prompt_run_iterations and captain_model_calls reach their session indirectly; that lookup must be preserved.
- Go writes messages in batches of 500 (
pkg/database/session_ingest_store.go:228), so 10,000 messages is roughly 20 statement invocations, not one.
Reading NEW.session_id and NEW.occurred_at directly instead of through to_jsonb(NEW) is a smaller, independently valuable change that removes the per-row serialization without restructuring the trigger.
Cheaper first move, worth evaluating in the same breath
migrations/72_ingest_storage_params.sql:23-31 records that captain_turns and captain_model_calls are rewritten in full on every transcript append, because turns carry running aggregates over the whole file. The turn and model-call upserts are unconditional (pkg/database/session_ingest_store.go:408, pkg/database/session_message_ingest.go:14). Adding IS DISTINCT FROM guards — the pattern convergeMessages already uses — suppresses no-op churn with no trigger surgery at all.
Also in scope
migrations/72_ingest_storage_params.sql:7 justifies its settings with "captain_messages is written once and never updated." That is false — convergeMessages updates role, parts, raw, turn_id, source_line and occurred_at. The tuning itself is defensible (it sets insert and analyze thresholds and cost delay, not a fillfactor chosen for immutability), so correct the comment without disturbing the parameters.
Acceptance Criteria
Verification
cwd: .
timeout: 20m
codeBlocks: [test, lint]
ai: {}
verify:
scope: diff
threshold: 80
Focused tests
paths: [./pkg/database/..., ./pkg/monitor/..., ./migrations/...]
framework: [go, ginkgo]
test-timeout: 12m
show-passed: true
Changed-code lint
changed: true
fix: false
timeout: 5m
Acceptance Criteria
Problem
captain_touch_session_activityis anAFTER INSERT OR UPDATE ... FOR EACH ROWtrigger installed on eight tables includingcaptain_messages(migrations/52_session_activity_triggers.sql:93-131). Its first statement is:row_data jsonb := to_jsonb(NEW);to_jsonb(NEW)serializes the entire tuple, including thepartsJSONB blob, purely so the function can readsession_idandoccurred_atas text (:46-69). It then issues anUPDATE public.captain_sessions.Ingesting a 10,000-message transcript executes 10,000 trigger functions and 10,000 full-row JSONB conversions.
convergeMessagesre-fires the trigger for every row itsUPDATE ... IS DISTINCT FROMactually touches (pkg/database/session_message_ingest.go:75-99).Physical session updates are fewer than trigger fires — the update is guarded on a newer timestamp (
:73,:86) — so the cost is dominated by function invocation and tuple serialization rather than by row updates.This issue is measurement-first
migrations/71_session_storage_params.sql:11records the symptom:But that migration attributes the churn broadly to heartbeats, state rewrites and triggers. Nothing in the tree isolates the
to_jsonbcost, and the applied remedy —fillfactor = 70plus autovacuum tuning — treats the bloat rather than whatever produces it.Measure before changing anything. Capture per-ingest trigger cost and
pg_stat_user_tables.n_tup_updoncaptain_sessionsfor a representative transcript.SessionStorageStats(pkg/database/session_maintenance_store.go:110-145) already reports the bloat telemetry to compare against.If measurement justifies a change
The rewrite must be per-table statement triggers using transition tables, not one generic drop-in:
NEW; they must consume aREFERENCING NEW TABLE AStransition relation.occurred_at,state_observed_at,started_atandresolved_at(:64-69). AggregatingMAX(occurred_at)alone silently changes behaviour for turns, model calls, prompt runs and turn requests.CASE TG_TABLE_NAMEindirection (:46-59) exists becausecaptain_prompt_run_iterationsandcaptain_model_callsreach their session indirectly; that lookup must be preserved.pkg/database/session_ingest_store.go:228), so 10,000 messages is roughly 20 statement invocations, not one.Reading
NEW.session_idandNEW.occurred_atdirectly instead of throughto_jsonb(NEW)is a smaller, independently valuable change that removes the per-row serialization without restructuring the trigger.Cheaper first move, worth evaluating in the same breath
migrations/72_ingest_storage_params.sql:23-31records thatcaptain_turnsandcaptain_model_callsare rewritten in full on every transcript append, because turns carry running aggregates over the whole file. The turn and model-call upserts are unconditional (pkg/database/session_ingest_store.go:408,pkg/database/session_message_ingest.go:14). AddingIS DISTINCT FROMguards — the patternconvergeMessagesalready uses — suppresses no-op churn with no trigger surgery at all.Also in scope
migrations/72_ingest_storage_params.sql:7justifies its settings with "captain_messages is written once and never updated." That is false —convergeMessagesupdatesrole,parts,raw,turn_id,source_lineandoccurred_at. The tuning itself is defensible (it sets insert and analyze thresholds and cost delay, not a fillfactor chosen for immutability), so correct the comment without disturbing the parameters.Acceptance Criteria
captain_sessionsupdate volume before any behavioural change.pg_stat_user_tables.n_tup_updoncaptain_sessionsper ingested transcript drops materially against that baseline.migrations/72_ingest_storage_params.sql:7is corrected without changing the storage parameters.Verification
cwd: .
timeout: 20m
codeBlocks: [test, lint]
ai: {}
verify:
scope: diff
threshold: 80
Focused tests
Changed-code lint
Acceptance Criteria
captain_sessionsupdate volume before any behavioural change.pg_stat_user_tables.n_tup_updoncaptain_sessionsper ingested transcript drops materially against that baseline.occurred_at,state_observed_at,started_atandresolved_atare all still honoured as activity sources.captain_prompt_run_iterationsandcaptain_model_callsis preserved.migrations/72_ingest_storage_params.sql:7is corrected without changing the storage parameters.