Conversations Index Cleanup
Tracking the diagnosis and remediation of LWLock:BufferMapping pressure on the conversations table that surfaced first as a wave of EU prod webhook timeouts on 2026-05-05.
Update 2026-05-06: the original diagnosis (10 GB of indexes overwhelming
shared_buffers) was a contributing factor but not the trigger. The actual root cause is a missing access path: the Zendesk, Freshdesk, and Jira importers issuefind_or_initialize_byqueries whose WHERE-clause columns don’t match any index onconversations, so Postgres falls back to a Parallel Sequential Scan over the whole 4 GB heap on every webhook. Under cold cache or any autovacuum stall, those seq-scans pile up onBufferMapping. See § Actual root cause (2026-05-06).
TL;DR
- The dominant Zendesk/Freshdesk/Jira webhook lookup runs as a Parallel Seq Scan over the entire
conversationsheap because its predicate(organization_id, data_source_id, external_id)doesn’t match the unique partial index(organization_data_source_id, external_type, external_id). Cold-cache mean is 14 s inpg_stat_statements; that’s what was timing out at the 60 sstatement_timeouton 2026-05-05. - The Intercom importer (added later) does match the index. The other three were never updated when the index moved to
organization_data_source_id. Same fire, three small diffs, mirrors a pattern that already exists. - The EU incident self-recovered without any of the runbook’s original “ad-hoc index drops” being executed. As of 2026-05-06 the system is healthy on the surface — but the seq-scan timebomb is still armed and will re-fire the next time the heap falls out of
shared_buffers. - Index footprint cleanup (the original “three-phase plan”) is still worth doing as housekeeping — frees ~1.5 GB on US, reduces autovacuum work — but it is no longer the priority and shouldn’t be framed as fixing the incident.
Symptom (2026-05-05)
EU prod, 2026-05-05 ~13:00 UTC
GoodJob dashboard:
- 5,021 discarded
ProcessZendeskWebhookEventJobin the last hour - 5,010 of those are
ActiveRecord::QueryCanceled: PG::QueryCanceled: ERROR: canceling statement due to statement timeout - 4,994 are from
organization_id = 33 - 129 discarded
CreateConversationPartRelationshipsJob, 120 of which are alsoQueryCanceled
The error trace points at:
app/models/conversation/import/zendesk.rb:9:in 'import'which is:
conversation = Conversation.find_or_initialize_by(
data_source: DataSource.zendesk!,
external_id: ticket.id
)Under ActsAsTenant.with_tenant(organization), this issues SELECT … FROM conversations WHERE organization_id = ? AND data_source_id = ? AND external_id = ? LIMIT 1. We initially assumed this hit a unique partial index and was bottlenecked by buffer-mapping contention. It does not hit a unique index — see § Actual root cause below.
Postgres statement_timeout is set to 60 s in config/database.yml; runtimes of 1m 1s confirmed the timeout was firing, not user-side cancellation.
Initial diagnosis (2026-05-05) — what we observed
The data points below are correct; the interpretation in this section is what 2026-05-06 revised. Kept here as the audit trail.
1. Live database state (EU)
pg_stat_activity:
pid 1992:autovacuum: VACUUM public.conversationsrunning for 1 h 30 m, stuck onLWLock:BufferMapping.- 19 other backends running long
SELECT conversations …queries, several stuck onLWLock:BufferMapping. - No
idle in transactionsessions. - No real lock contention chains (only routine GoodJob advisory locks, all under 1 s).
Top wait events:
| Wait | Count |
|---|---|
LWLock:BufferMapping | 152 |
null (running) | 12 |
Client:ClientRead | 12 |
IPC:ParallelBitmapScan | 4 |
LWLock:LockManager | 2 |
Lock:advisory (GoodJob) | 2 |
2. Table & index footprint
| Object | Total | Heap | Indexes |
|---|---|---|---|
conversations | 23 GB | 4 081 MB | 10 GB across 80+ indexes |
conversation_parts | 15 GB | 8 337 MB | 6 997 MB |
Bloat:
| Table | Live | Dead | Dead % | Last autovacuum |
|---|---|---|---|---|
conversations | 2 144 107 | 251 710 | 10.5 % | 2026-05-05 02:44 UTC |
conversation_parts | 49 943 305 | 52 877 | 0.1 % | 2026-04-28 |
Autovacuum progress (the stuck process):
phase: vacuuming indexes
heap_blks_total: 522 362
heap_blks_scanned: 522 362 # heap pass done
heap_blks_vacuumed: 0 # waiting on index pass
index_vacuum_count: 0 # no indexes finished after 90+ min
num_dead_tuples: 210 1573. Performance Insights — historical view
EU prod DB (db-HEVXLD2JB6JGDVN53QRQ7DAJEE, eu-central-1, PI enabled with 7-day retention):
LWLock:BufferMapping spikes had been climbing daily:
| Date | Peak AAS |
|---|---|
| 2026-04-29 | ~20 |
| 2026-04-30 | ~70 |
| 2026-05-01 | ~75 |
| 2026-05-02 | ~70 |
| 2026-05-03 | ~35 |
| 2026-05-04 | ~25 |
| 2026-05-05 | ~110 |
Max vCPU is ~5, so the 2026-05-05 peak was 22× over CPU capacity.
Top SQL by load during the spike (filtered to LWLock:BufferMapping):
| Query | AAS | Calls/s | Notes |
|---|---|---|---|
SELECT conversations.* … (the webhook find_or_initialize_by) | 21.5 | 1.18 | 75% of total load. ~18 s avg duration; mostly CPU + BufferMapping waits |
SELECT good_jobs.* … (queue poll) | 4.4 | 13.2 | Routine |
SELECT good_jobs.* … (queue poll variant) | 3.2 | 7.7 | Routine |
SELECT pg_advisory_xact_lock(…) | 0.4 | 24.8 | Routine GoodJob |
COMMIT | 0.4 | 51.5 | Routine |
4. Original (incorrect) interpretation
We initially concluded that the index footprint (10 GB / 80+ indexes on a 4 GB heap) was the structural cause: every BufferMapping slot was contended, autovacuum couldn’t make progress through the bloated index pass, and a query that “should be” a unique-index hit was waiting on locks. The remediation plan was a three-phase index drop.
What that explanation got right:
- The autovacuum was stuck.
- Index bloat is real and was making the autovacuum’s index pass slow.
- Dropping unused indexes is a sensible housekeeping action.
What it got wrong:
- The webhook query was never going to be a unique-index hit, regardless of
BufferMappingpressure. It was a Parallel Seq Scan over the whole heap on every call. - The actual amplifier was: every webhook = 4 GB of buffer pages touched. With 1.18 calls/s and 8+ workers, that saturates
shared_buffersand produces exactly theBufferMappingwaits we saw, even without a stuck autovacuum. - The stuck autovacuum was a symptom of the same buffer pressure (every page of every index it tried to read had to fight the seq-scans), not its cause.
Actual root cause (2026-05-06)
Re-checked EU prod on 2026-05-06 with the same diagnostic queries. Headline findings:
| Signal | 2026-05-05 (incident) | 2026-05-06 | Verdict |
|---|---|---|---|
LWLock:BufferMapping sessions | 152 | 0 | resolved |
Stuck autovacuum on conversations | 1h30m, on vacuuming indexes | none running | resolved |
pg_indexes_size('conversations') | ~10 GB / 80+ idx | 11 GB / 84 idx | unchanged / slightly worse |
| Phase 1+2 ad-hoc drops applied | — | 0/8 | not done |
conversations last_autovacuum | 2026-05-05 02:44 | 2026-05-06 17:00 (1h ago) | healthy |
conversations autovacuum_count | (not captured) | 175 | autovacuum reaching completion |
conversations dead % | 10.5% | 6.4% | healthy |
| Webhook timeouts (1h) | 5 010 | 0 (770 ok, 14 other) | resolved |
The system self-recovered. Nothing was dropped. The fire is out — and that fact alone disproves the “structural index bloat” framing.
EXPLAIN of the actual hot query
ActsAsTenant.with_tenant(Organization.find(33)) do
ds = DataSource.zendesk!
sample = "diagnostic-#{SecureRandom.hex(4)}"
sql = Conversation.where(data_source: ds, external_id: sample).limit(1).to_sql
pp ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS) #{sql}").to_a
endLimit (cost=1000.00..539192.35 rows=1 width=3637) (actual time=420.969..422.658 rows=0 loops=1)
Buffers: shared hit=434464 read=87898
I/O Timings: shared read=196.952
-> Gather (cost=1000.00..539192.35 rows=1 width=3637)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on conversations (cost=0.00..538192.25 rows=1 width=3637)
Filter: ((organization_id = 33) AND (data_source_id = 1) AND (external_id = 'diagnostic-90e115ec'::text))
Rows Removed by Filter: 727397
Buffers: shared hit=434464 read=87898
Execution Time: 422.720 msEven hot, this query touches 522,362 buffer pages (= the whole 4 GB heap) per call. That matches pg_stat_statements:
| Query | Calls | Mean | Notes |
|---|---|---|---|
find_or_initialize_by SELECT (the importer) | 1.68 M | 14 s | 23 B ms cumulative — the dominant load on the cluster |
| Variant of same SELECT | 10 K | 16 s | Same shape, different bind |
| Variant of same SELECT | 108 K | 1.5 s | Same shape |
UPDATE … conversation_thread_id | 28 M | 38 ms | Routine |
Existence check (SELECT 1 ... !=) | 49 M | 0.6 ms | Routine — this one is hitting an index |
The 49 M-call existence check averaging 0.6 ms is the AR uniqueness validator on Conversation:
validates :external_id, uniqueness: { scope: %i[organization_data_source_id external_type] },
if: -> { organization_data_source_id.present? && external_id.present? }It filters by (external_id, organization_data_source_id, external_type) — which exactly matches the unique partial index — and runs in 0.6 ms. Same row, same column set, same data; just queried through a different access path.
The unique index that the importer should be hitting
CREATE UNIQUE INDEX idx_conversations_org_ds_external_type_external_id
ON public.conversations
USING btree (organization_data_source_id, external_type, external_id)
NULLS NOT DISTINCT
WHERE ((organization_data_source_id IS NOT NULL) AND (external_id IS NOT NULL));Index leading columns: (organization_data_source_id, external_type, external_id).
Importer query columns: (organization_id, data_source_id, external_id) (with organization_id injected by acts_as_tenant).
Zero columns in common that the planner can use as an access path — so it falls back to seq-scan.
Where the bug lives, and why
| Importer | Query columns | Index match | Status |
|---|---|---|---|
Conversation::Import::Intercom (intercom.rb:12) | (organization_data_source, external_type, external_id) | exact | correct |
Conversation::Import::IntercomTicket (intercom_ticket.rb:43) | (organization_data_source, external_type, external_id) | exact | correct |
Conversation::Import::Zendesk (zendesk.rb:9) | (data_source, external_id) + injected organization_id | none | bug |
Conversation::Import::Freshdesk (freshdesk.rb:9) | (data_source, external_id) + injected organization_id | none | bug |
Conversation::Import::Jira (jira.rb:18) | (data_source, external_id) + injected organization_id | none | bug |
Likely chronology: the Conversation schema originally indexed (organization_id, data_source_id, external_id) (or had no unique index). When the organization_data_source association was introduced — encoding (organization_id, data_source_id) as a single FK — the unique index migrated to (organization_data_source_id, external_type, external_id). The Intercom importer was written (or rewritten) against the new shape. The Zendesk, Freshdesk, and Jira importers were never updated and silently degraded to seq-scans, then stayed there until the heap got large enough for the seq-scans to time out.
The Zendesk importer’s retry_import_existing fallback (line 32) does pass organization_data_source: — so the cold path hits the index correctly. The hot path doesn’t.
Why org 33 was disproportionately affected
Org 33 is a Zendesk tenant with the largest conversations row count. Every other Zendesk tenant was running the same seq-scan on the same heap; their webhook calls were just less frequent. There is nothing wrong with org 33’s data — they were the canary, not the cause.
Plan
Five workstreams, ordered by urgency. Each can ship independently.
Workstream 1 — Fix the importer queries (high urgency, low risk)
Status: not started.
Change Zendesk, Freshdesk, and Jira importers to issue find_or_initialize_by(organization_data_source: …, external_type: nil, external_id: …) so they match the existing unique partial index. Mirrors the Intercom pattern.
No data migration required:
- The unique index uses
NULLS NOT DISTINCT, so existing rows withexternal_type IS NULLwill still match queries that passexternal_type: nil. - Verified on 2026-05-06 that
count(*) FILTER (WHERE organization_data_source_id IS NULL) = 0for Zendesk, Freshdesk, and Jiraconversationsrows in both EU and US prod. So the new query — which filters byorganization_data_source_id— won’t miss legacy rows.
Expected outcome: the EXPLAIN above turns from Parallel Seq Scan into Index Scan using idx_conversations_org_ds_external_type_external_id, with Buffers: shared hit=4 and sub-millisecond execution.
Verification:
- Run the EXPLAIN snippet above before and after the deploy.
pg_stat_statementsmean for the importer query should drop from ~14 s to ~1 ms within minutes of deploy. Reset stats first to make this clean.LWLock:BufferMappingpeak AAS in PI should flatten on the next mid-day load period.
This is the single change that prevents the next incident.
Workstream 2 — Audit other seq-scan callsites (medium urgency, low risk)
Status: not started.
There are several Conversation.find_by(external_id: …) callsites that filter by external_id without organization_data_source_id. Same anti-pattern, lower call frequency:
app/models/organization_data_source.rb:1115(find_by(external_id:, data_source:))app/models/workflow_node/run/create_jira_issue_from_ticket.rb:69app/models/workflow_node/run/create_jira_issue_comment.rb:54app/models/simulation.rb:575app/models/sentinel_conversation_message.rb:103app/models/workflow_run/tools/get_conversation_by_external_id_tool.rb:18app/models/digest_email/tools/get_conversation_details.rb:38lib/tasks/conversations.rake:138(Conversation.where(data_source:, handling_time: nil))
Each should be reviewed and either pass organization_data_source: (preferred) or be confirmed to run rarely enough that a seq-scan is acceptable. The rake task is run by an operator and is fine. The workflow/sentinel/digest paths happen on user actions and should be tightened.
Defer until Workstream 1 lands and stabilises.
Workstream 3 — Decide whether to add organization_id to the unique index (low priority, probably skip)
Status: open question, recommendation: don’t.
Adding organization_id as a leading column to idx_conversations_org_ds_external_type_external_id would force a 4 GB index rebuild and buy nothing — organization_data_source_id is strictly more selective than organization_id (every ODS belongs to exactly one org). The only case where leading with organization_id would help is a query that filters by organization_id but not organization_data_source_id, and we have none of those for external_id lookups.
Document the decision and close.
Workstream 4 — Index footprint cleanup (low urgency, housekeeping)
Status: not started.
The original three-phase plan from this runbook is still valid as housekeeping. It is not firefighting. It frees disk, reduces autovacuum work, and shrinks the structural risk if Workstream 1 ever regresses — but the system was never going to be made healthy by drops alone, and that is now demonstrated.
Do these as a normal-cadence migration, not under incident pressure. Tables retained verbatim from the original analysis below for reference.
Phase 4a — completely safe (drop now)
Six indexes. ~640 MB on US, ~500 MB on EU. No judgment calls.
| Index | Definition | EU/US scans | EU/US size |
|---|---|---|---|
index_conversations_on_qa_score_updated_at | btree (qa_score_updated_at) | 0 / 0 | 45 / 98 MB |
index_conversations_on_jira_request_type_id | btree (jira_request_type_id) | 0 / 0 | 35 / 37 MB |
index_conversations_on_customer_account_id | btree (customer_account_id) | 0 / 0 | 35 / 36 MB |
index_conversations_on_compliance_review_id | btree (compliance_review_id) | 0 / 0 | 35 / 35 MB |
index_conversations_on_evaluation_contest_outcomes | gin (evaluation_contest_outcomes) | 0 / 0 | 10 / 12 MB |
idx_on_organization_id_interaction_type_started_at_44de9acb05 | btree (organization_id, interaction_type, started_at) | 0 / 655 | 179 / 318 MB |
The last one is a strict column prefix of idx_on_organization_id_interaction_type_started_at__43bf513fd0 ((organization_id, interaction_type, started_at, active_evaluation_id), 85 K+ EU scans) — Postgres can serve any query the prefix handles using the longer one. Free drop.
Phase 4b — strongly recommended (low risk)
Two indexes. ~850 MB on US, ~480 MB on EU.
The compliance and dispute risk-level indexes each have a “short” 3-column variant and a “long” 5-column variant where the short is a strict prefix of the long. Drop the long variants — they’re 10× larger, used 50× less, and the short variants pick up their queries with only a sort cost.
| Index to drop | Definition | EU/US scans | EU/US size | Covered by |
|---|---|---|---|---|
idx_on_organization_id_interaction_type_compliance__2c5a1b0478 | btree (organization_id, interaction_type, compliance_risk_level, external_created_at DESC, created_at DESC) | 0 / 753 | 242 / 426 MB | idx_on_…_compliance__15c7b570ac (33 987 US scans — kept) |
idx_on_organization_id_interaction_type_dispute_ris_df1730f4e7 | btree (organization_id, interaction_type, dispute_risk_level, external_created_at DESC, created_at DESC) | 0 / 2 404 | 242 / 427 MB | idx_on_…_dispute_ris_1c9318ac66 (1 487 US scans — kept) |
Mild risk: dispute/compliance pagination queries on US gain a sort step. Filtered set is small, probably fine.
Phase 4c — opportunistic (verify with codebase grep, then drop)
Single-column indexes used very rarely on US over the lifetime of the DB. Each candidate should get a quick Grep for where(<column>: …) patterns before dropping to confirm it’s not tied to an active feature.
| Index | Definition | EU/US scans | EU/US size | Verify by checking |
|---|---|---|---|---|
index_conversations_on_sla_breach_last_checked_at | btree (sla_breach_last_checked_at) | 0 / 1 | 78 / 175 MB | Worst scan/MB ratio. SLA-breach worker scan? |
index_conversations_on_first_agent_response_at | btree (first_agent_response_at) | 0 / 135 | 103 / 209 MB | First-response analytics |
index_conversations_on_first_customer_message_at | btree (first_customer_message_at) | 0 / 15 | 52 / 155 MB | First-response analytics |
index_conversations_on_first_agent_reply_at | btree (first_agent_reply_at) | 0 / 14 | 43 / 93 MB | First-response analytics |
index_conversations_on_qa_auto_fail | btree (qa_auto_fail) | 0 / 7 | 43 / 36 MB | Boolean column — generally a poor index target |
index_conversations_on_evaluation_state | btree (evaluation_state) | 0 / 2 | 39 / 43 MB | where(evaluation_state: …) callsites |
Keep (for now)
idx_on_organization_id_interaction_type_compliance__15c7b570ac— 33 987 US scansidx_on_organization_id_interaction_type_dispute_ris_1c9318ac66— 1 487 US scansindex_conversations_on_translation_state— 211 US scans, decent ratioindex_conversations_on_transcription_state— 102 US scans, decent ratioindex_conversations_on_jira_issue_key— small (5 MB), partial index, recent
Workstream 5 — Deprecate external_type column (out of scope for now)
Status: deferred. Tracked here so it doesn’t get lost.
Decision (2026-05-06): we want to merge Intercom conversations and tickets into a single conversations row rather than disambiguating with external_type. That makes external_type dead weight — the unique index can collapse to (organization_data_source_id, external_id).
Pre-requisites before this can happen:
- Design a dupe-merge strategy for any existing rows where the same
(organization_data_source_id, external_id)was inserted with two differentexternal_typevalues. Need to know how many rows are affected, what to merge into what, and what downstream consumers (events, evaluations, threads) need to be updated. - Decide whether
IntercomTicketcontinues to exist as a separate importer or is merged into the Intercom importer. - Write a migration: consolidate dupes → drop the column → drop the index → recreate the unique index without
external_type.
Do not start until Workstreams 1 and 4 have landed.
Status
| Workstream | Step | State | Notes |
|---|---|---|---|
| — | Diagnosis (initial, 2026-05-05) | done | Section retained above |
| — | Diagnosis (root cause, 2026-05-06) | done | EXPLAIN, importer audit |
| — | EU recovery verification | done | Self-recovered; no drops were performed |
| 1 | Confirm no legacy NULL-ODS rows on EU + US | done | 2026-05-06: zero matches for zendesk/freshdesk/jira |
| 1 | Patch Zendesk importer | not started | app/models/conversation/import/zendesk.rb |
| 1 | Patch Freshdesk importer | not started | app/models/conversation/import/freshdesk.rb |
| 1 | Patch Jira importer | not started | app/models/conversation/import/jira.rb |
| 1 | Verify EXPLAIN flips to Index Scan in prod | not started | After deploy |
| 1 | Reset & re-check pg_stat_statements | not started | Confirm mean drops sub-ms |
| 2 | Audit other find_by(external_id:) callsites | not started | 7+ callsites identified |
| 3 | Document decision on adding organization_id to unique index | not started | Recommendation: skip |
| 4a | Phase 4a migration (6 indexes) | not started | Housekeeping |
| 4b | Phase 4b migration (2 indexes) | not started | Housekeeping |
| 4c | Phase 4c codebase grep + migration | not started | Housekeeping |
| 5 | Dupe-merge design for external_type removal | deferred | Pre-req to drop |
Follow-ups (out of scope for this cleanup)
- Investigate
index_conversations_on_data_source_idusage. 1.7 Tidx_tup_readfor 2 M scans is ~850 K rows per call. Almost certainly another query somewhere doingConversation.where(data_source: …)without anorganization_data_sourcescope. Workstream 2 will cover most of these; this index in particular is worth a separate look once Workstream 1 lands and the noise floor is lower. - Review autovacuum settings on
conversations. Even with the access-path fix, dead-tuple churn at this scale may benefit from a lowerautovacuum_vacuum_scale_factorfor this table. - Consider per-table autovacuum cost limits so a heavy autovacuum can finish faster instead of throttling itself.
shared_bufferssizing on the EU instance vs. working-set growth — less critical now that the seq-scans are going away, but still worth a sanity check.- Monitor for index-bloat regression. Snapshot
pg_stat_user_indexes.idx_scanperiodically (e.g. nightly cron writing to a smallindex_usage_snapshotstable) so we have freshness signal next time we need it. Postgres 16 gives uslast_idx_scanfor free; until we upgrade, snapshots are the workaround. - Add a guard test that fails CI if a new
Conversation.find_by(external_id: …)callsite is introduced withoutorganization_data_source— at minimum a Rubocop pattern matcher or a request-spec-level assertion that webhook lookups produce an Index Scan.