Failure model

July 28, 2026 · View on GitHub

This document states the behavior the implementation is designed to produce. Observed test evidence belongs in verification.md; expected behavior is not a substitute for a run.

Assumptions

  • PostgreSQL 14–18 and PGMQ 1.12.0 operate according to their documented transaction and visibility semantics.
  • Queue tables are durable unless the caller explicitly chooses QueueStorage::unlogged.
  • PostgreSQL evaluates whether a stored PGMQ visibility timestamp has expired. Caller-supplied absolute delivery/visibility TimePoint values must identify the wall-clock instant the caller intends.
  • Transactional retry sends a millisecond duration and PostgreSQL derives the deadline from clock_timestamp() inside the transaction; it does not depend on the application wall clock.
  • Both worker modes accept retry durations only from 0 through 9,007,199,254,740 milliseconds. This exact-interval bound is validated before visibility SQL.
  • Regular-worker lease scheduling instead uses visible_at - database_observed_at from one database query and maps that duration to steady_clock; it does not subtract the host wall clock.
  • Handler code may fail, block, ignore cancellation, or perform non-transactional side effects.
  • Transactional handlers are trusted to follow the WorkerTransaction contract and not use arbitrary execute() SQL to end the transaction or acknowledge/change the current task.
  • Processes, database connections, and notification sessions can disappear at any instruction boundary.
  • There is no distributed transaction with an external service.

Failure matrix

FailureDetectionQueue/business outcomeDuplicate riskRequired response
Handler returns retryexplicit resultbusiness effect is caller-defined; source is rescheduledYesMake prior effects idempotent
Explicit retry delay or configured policy is negative or exceeds 9,007,199,254,740 msHandlerResult::retry, queue registration, and runtime guardsinvalid_input / worker_retry occurs before visibility SQL; an in-handler or aggregate-bypass result emits handler_failed and is dead-lettered oncePrior external effects may repeatCorrect the duration/policy; do not use an absolute timestamp as a duration
Handler throws retryable pgmq::Errorexception categoryregular mode schedules retry; transactional mode rolls back handler savepoint then schedulesYesInspect event/error context
Handler throws non-retryable pgmq::Errorexception categoryatomic DLQ moveLow for queue move; prior external effect may repeatInspect DLQ
Other handler exceptioncatch-allretry, then DLQ at attempt limitYesClassify explicitly when possible
Regular lease renewal fails before deadlinerenewal eventshort bounded retry while original deadline remainsPossible if outage outlasts leaseHandler should observe stop request if lease becomes lost
Lease expires or conditional renewal affects no rowlease_lostno ack/retry/DLQ by stale worker; message may have a newer ownerOld and new handlers may overlapIdempotency; stop cooperatively
DB disconnect before a new SDK operationlibpq health checkreconnect before sending operationNormal operation semanticsMonitor reconnects
DB disconnect during one autocommit statementlibpq errorserver outcome can be unknown; SDK does not replay blindlyProducer write may have committedReconcile/idempotently retry
DB disconnect inside caller transactionerror; reconnect disabledconnection loss causes server rollback unless commit already completedCommit outcome can be unknownReconcile durable state
Connection pool exhausted beyond pool_acquire_timeouttimeout-category pgmq::Erroroperation was not sentNone from that operationIncrease capacity, shorten leases, or reduce contention
Runtime thread creation fails during WorkerRuntime::start()thread constructor exceptionalready launched runtime threads are stopped and joined; a later start() rebuilds all per-run stateWork claimed during a partially completed multi-queue start can become visible again after its leaseLog the failure, relieve the process resource limit, then retry start()
Invalid QueryResult row/column lookupstd::out_of_range, or std::invalid_argument for a null C-string namequery has already completed; result access fails without another database operationNoneCorrect the index/name or check result shape
Notification lost/coalesced/throttledusually not directly knowablepolling later finds rowNone from notification aloneKeep polling enabled
Listener disconnectlistener reconnect counter/eventLISTEN and configuration restored; race covered by pollNone from notification aloneMonitor reconnect rate
Notification setup unsupported/deniedsetup error in direct SDK; worker falls backworker pollsHigher latency/query loadFix version/privileges if notifications desired
Process crash in regular handlerno local cleanuplease stops renewing; source becomes visible after vtYesRestart worker; use idempotency
Process crash in transactional handler before commitPostgreSQL session rollbackclaim, handler SQL, and ack all roll backHandler may rerun, but database partial state does not persistRestart worker
Shutdown grace timeoutShutdownResult and eventno remaining ack; renewal stopsYes after lease expiryApplication controls process lifetime
Metrics/event sink failurecallback exception is swallowedtask processing continuesNoneMake sink independently reliable
PostgreSQL crash with durable queuePostgreSQL recoverycommitted rows follow PostgreSQL durabilityDepends on commit acknowledgement ambiguityFollow PostgreSQL recovery procedures
PostgreSQL crash with unlogged queuePostgreSQL semanticsunlogged queue can be truncatedData lossDo not use for durable work
Partition maintenance errorPostgreSQL/pg_partman erroraffected operation failsDepends on caller retryRepair pg_partman/retention configuration

Deterministic process-crash boundaries

Crash tests use a separate project-owned worker process and deterministic sync points. The expected state transitions are:

Kill pointRegular modeTransactional mode
Before claimMessage remains visible/unclaimedMessage remains visible/unclaimed
After claim, before handlerInvisible until lease expiry, then redeliveredOpen transaction rolls back on connection death; message is available
During handlerRedelivered after lease expiryHandler SQL and claim roll back
External side effect complete, before ackSide effect may be duplicated after redeliveryExternal effect may be duplicated; it was never covered by PostgreSQL atomicity
Before transaction commitNot applicable to regular external work as one atomic unitBusiness rows and ack both absent after rollback
After ack/commit has completedSource is deleted/archived; no redeliveryBusiness rows and ack both present

“After commit” must mean the test process crossed a synchronization point after PostgreSQL returned successful commit—not merely that COMMIT was sent.

The verification report must record total unique tasks, final queue/business state, and observed duplicate count. If a crash scenario was not run, its status is not run, not inferred from this table.

Unknown commit outcome

A network failure can occur after PostgreSQL commits but before the client receives the success response. The database remains atomic, but the caller does not know which side of commit occurred.

The library intentionally does not reconnect and replay a transaction. Applications should:

  1. choose a stable operation id;
  2. store it with a unique constraint in the same transaction;
  3. query durable state after reconnect;
  4. retry only through an idempotent path.

The same issue exists for a producer send executed as one autocommit statement: if the connection fails mid-response, the message may exist. Put an application event id in its body/headers if duplicate sends matter.

Regular completion failures

Regular terminal actions run in their own transaction after the handler:

  • ownership is locked and checked;
  • the mutation is made;
  • commit determines success.

If ownership has changed, the action fails closed as lease_lost. If the database operation itself fails, the runtime emits handler_failed with completion context and does not claim success. The existing message eventually becomes visible unless the server actually committed and only the response was lost. This conservative behavior favors no silent deletion over optimistic acknowledgement.

Dead-letter failure

The DLQ send and source delete share one transaction. Expected invariants:

  • commit: destination exists and source does not;
  • rollback/failure: source remains and destination does not;
  • stale ownership: neither move nor delete is attempted.

The destination queue is created at runtime start. Failure to create it causes startup to fail instead of deferring a predictable dead-letter error.

Notification failure

Notifications are intentionally absent from correctness invariants:

  • the trigger may throttle a burst;
  • PostgreSQL may coalesce repeated same-channel empty-payload notifications;
  • a producer transaction can roll back, in which case no committed message exists;
  • a listener may be disconnected;
  • LISTEN restoration has a race.

The only durable fact is the queue row. Polling checks that fact.

Shutdown failure

std::stop_token is cooperative. A handler that blocks forever cannot be safely terminated by the runtime.

On timeout, shutdown() returns without pretending the drain succeeded. It disables acknowledgements for unfinished regular claims and stops renewal. The application must not destroy objects referenced by the handler while it is still executing. Depending on deployment policy, it may log and keep waiting, escalate to process termination, or isolate handler work in a separately killable process.

No timeout path acknowledges unfinished work.

Explicitly outside the model

pgmq-cpp does not solve:

  • Byzantine database behavior or silent storage corruption;
  • arbitrary code continuing safely after memory corruption;
  • exactly-once calls to external systems;
  • atomic commits across databases;
  • recovery of an unlogged queue after PostgreSQL crash;
  • application logic that uses a non-unique or changing idempotency key.