Summary
workspace_backend 0.1.18 can enter a permanent MessengerWorkerAgent retry loop in prune_expired_events().
After a bounded batch deletes the last events for multiple audience snapshots, the final cursor upsert emits one row per audience membership. If two orphaned snapshots contain the same (project_id, user_uuid), PostgreSQL receives duplicate values for the unique cursor key in a single INSERT ... ON CONFLICT DO UPDATE statement and raises:
psycopg.errors.CardinalityViolation: ON CONFLICT DO UPDATE command cannot affect row a second time
The worker calls refresh_effective_capabilities() before pruning in the same transaction. That function locks external-account rows first; the new bounded pruning query then takes per-project advisory locks. Concurrent message writes and provider-event batches take the project advisory lock first and then need an external-account row lock/FK check. This lock-order inversion produces recurring PostgreSQL deadlocks while the worker is retrying.
Impact
- Event and heartbeat retention cleanup does not commit.
- External projection repair in the same worker iteration is skipped.
- Messenger writes and read-state updates can return HTTP 500 with
DeadlockDetected.
- Provider-event batches can fail and require retry.
- The systemd units remain
active, so service-state monitoring alone does not expose the failure.
Reproduction
- Create at least two audience snapshots for the same project with the same member.
- Give each snapshot only expired broadcast events that will be selected by one pruning batch.
- Run
prune_expired_events() with a batch size large enough to delete the last event for both snapshots.
- The final insert into
m_workspace_event_cursors contains duplicate (project_id, user_uuid) values and fails with CardinalityViolation.
- Run a concurrent externally-linked message write or provider-event batch while the worker holds account locks and waits for the project advisory lock; one transaction is aborted as a deadlock victim.
This is reproducible against tag 0.1.18; the bounded pruning/locking behavior was introduced by #55.
Expected behavior
The orphan-snapshot cursor source should produce at most one row per (project_id, user_uuid), folding watermarks with MAX(...) before the upsert. Lock acquisition order should also be consistent across worker, messenger-write, and provider-event paths, or pruning should run in a transaction isolated from capability refresh.
Regression coverage
Please add coverage for:
- multiple orphaned audience snapshots sharing one project/member in the same batch;
- cursor watermarks taking the maximum across those snapshots;
- concurrent pruning and externally-linked message/provider-event writes without deadlocks;
- worker iteration rollback not starving projection repair or retention indefinitely.
Summary
workspace_backend0.1.18 can enter a permanentMessengerWorkerAgentretry loop inprune_expired_events().After a bounded batch deletes the last events for multiple audience snapshots, the final cursor upsert emits one row per audience membership. If two orphaned snapshots contain the same
(project_id, user_uuid), PostgreSQL receives duplicate values for the unique cursor key in a singleINSERT ... ON CONFLICT DO UPDATEstatement and raises:The worker calls
refresh_effective_capabilities()before pruning in the same transaction. That function locks external-account rows first; the new bounded pruning query then takes per-project advisory locks. Concurrent message writes and provider-event batches take the project advisory lock first and then need an external-account row lock/FK check. This lock-order inversion produces recurring PostgreSQL deadlocks while the worker is retrying.Impact
DeadlockDetected.active, so service-state monitoring alone does not expose the failure.Reproduction
prune_expired_events()with a batch size large enough to delete the last event for both snapshots.m_workspace_event_cursorscontains duplicate(project_id, user_uuid)values and fails withCardinalityViolation.This is reproducible against tag
0.1.18; the bounded pruning/locking behavior was introduced by #55.Expected behavior
The orphan-snapshot cursor source should produce at most one row per
(project_id, user_uuid), folding watermarks withMAX(...)before the upsert. Lock acquisition order should also be consistent across worker, messenger-write, and provider-event paths, or pruning should run in a transaction isolated from capability refresh.Regression coverage
Please add coverage for: