Skip to content

feat(state): add PostgresBackend, a state backend stored in the pgmq … - #408

Open
mducros-wm wants to merge 17 commits into
masterfrom
feat/pgmq-state-backend
Open

feat(state): add PostgresBackend, a state backend stored in the pgmq …#408
mducros-wm wants to merge 17 commits into
masterfrom
feat/pgmq-state-backend

Conversation

@mducros-wm

Copy link
Copy Markdown
Contributor

…message

The backend keeps no store of its own: a message's state is its pgmq row. Pending/Started, the three timestamps and the attempt count are derived from PGMQ's enqueued_at, last_read_at, archived_at and read_ct, and the terminal status is merged into the archive the broker already writes on ack/nack, so tracking a message's whole lifecycle costs no additional statement.

To make that merge possible, StateBackend.set_state takes an optional message keyword argument, which the state middleware now always passes. A state backend defined outside remoulade must accept it.

Unlike the Redis and stub backends, get_states/get_states_count honour their filters, sorting and pagination in SQL. set_progress is unsupported and raises, since storing a progress would mean an UPDATE on the queue table per call.

The broker's hand-written SQL moves into RemouladePostgresClient (remoulade.helpers.postgres_client), which also gains the indexes the state lookups need: (message->>'message_id') on queue and archive tables, and (headers->>'status') on archive tables. Index creation happens with the queue, so existing queues need a one-off backfill -- see the changelog.

…message

The backend keeps no store of its own: a message's state *is* its pgmq row.
Pending/Started, the three timestamps and the attempt count are derived from
PGMQ's enqueued_at, last_read_at, archived_at and read_ct, and the terminal
status is merged into the archive the broker already writes on ack/nack, so
tracking a message's whole lifecycle costs no additional statement.

To make that merge possible, StateBackend.set_state takes an optional
`message` keyword argument, which the state middleware now always passes. A
state backend defined outside remoulade must accept it.

Unlike the Redis and stub backends, get_states/get_states_count honour their
filters, sorting and pagination in SQL. set_progress is unsupported and
raises, since storing a progress would mean an UPDATE on the queue table per
call.

The broker's hand-written SQL moves into RemouladePostgresClient
(remoulade.helpers.postgres_client), which also gains the indexes the state
lookups need: (message->>'message_id') on queue and archive tables, and
(headers->>'status') on archive tables. Index creation happens with the
queue, so existing queues need a one-off backfill -- see the changelog.
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/helpers/postgres_client.py Outdated
PostgresBroker's hand-written statements interpolate the queue name into a
SQL identifier, which no bind parameter can carry. Check it at declare_queue,
the single gate every queue name goes through, so a name that cannot be used
as an identifier fails loudly when actors are declared.

Reuse the character set remoulade already enforces on every actor declaration
(actor.QUEUE_NAME_PATTERN, promoted from _queue_name_re) rather than defining a
second, divergent rule. Only the length bound is specific to PostgreSQL: past
47 characters the longest index name remoulade derives (q_<queue>_msg_id_idx)
exceeds PostgreSQL's 63-byte cap, gets truncated, and two long queue names
collide on one index.
… on every declare

Two fixes from the review of this branch.

PostgresBackend.set_state passed no connection to patch_headers, so the UPDATE
took a second connection from the pool instead of the broker's open transaction.
Inside broker.tx() -- what group.run() uses with group_transaction=True -- it
could not see the rows that transaction had just written, so the status was
dropped and patch_headers reported False; and a thread already holding a
connection asked the pool for another, blocking until the pool timed out. Route
it through the broker's transaction, which is None outside one and lets the
client open a transaction for the single statement, as before.

declare_queue no longer leaves an existing queue's indexes to a manual
create_indexes call. Nothing warns when one is missing and nothing fails -- the
queue merely seq scans every partition on each archive and set_vt -- so the
declaration is what has to repair it, rather than an operator knowing to. It now
ensures them on every declaration, new queue or not, which makes the
create_partitioned_queue override a passthrough; drop it.
PostgresBackend.set_state patched headers on whatever queue the state named. A
Message built by hand -- Message(queue_name=...) or Message.copy -- skips the
validation the actor API does, so that name is plain application data, and
patch_headers interpolates it into the UPDATE as a SQL identifier.

The reachable path is an enqueue that fails: Broker.enqueue emits
after_enqueue(exception=...) for the message, MessageState saves Failure, and
the state carries the undeclared queue name. emit_after logs and swallows, so
what it produced was a wasted UPDATE on the throughput-critical queue table and
a misleading Postgres traceback logged at critical, hiding the QueueNotFound the
caller actually got. Statement execution never succeeded -- the tail of the
statement spans lines a `--` cannot reach, and psycopg's extended protocol needs
both bind params satisfied -- but the name has no business reaching SQL.

Check it against broker.queues, the queues the broker declared, the same guard
the broker already applies on enqueue and consume. A queue that is not there
holds no message to patch, so there is nothing to look in either way.
…y client

pgmq sets SQLAlchemyPGMQueue to None rather than failing when its SQLAlchemy
backend cannot be imported, so RemouladePostgresClient subclasses None and the
module raises TypeError, not ImportError. That escaped the guard, failing
`import remoulade.state.backends` outright and taking RedisBackend and
StubBackend down with it in an environment that never asked for Postgres.
set_state raised NotImplementedError on any state carrying a progress. Since
Message.set_progress goes through set_state, an actor reporting its progress in
a loop failed mid-work and retried forever -- losing far more than the progress
it could not store. Ignore the progress instead: a state that also carries a
terminal status now gets that status recorded rather than losing everything to
the exception. Nothing else is needed, a progress-only state already falls
through the TERMINAL_STATUSES check just below. Public API untouched.

Also two docs that no longer described the code: stage_headers promised that "a
progress update followed by a terminal status both land", unreachable by design,
and a comment still named _PostgresMessage, renamed PostgresMessage on this
branch.
PostgresBackend had two ways to record a status: staged on the in-flight message
and written by the archive the broker performs anyway, or, with no message at
hand, an UPDATE on the queue table matched on (message->>'message_id').

That second path has no legitimate caller. MessageState always passes the
message; the processing hooks pass a PostgresMessage and take the staged route,
and the only hook passing a plain Message is after_enqueue, which records a
status just when the enqueue raised -- and an enqueue that raised wrote no row,
so there is nothing to patch. What is left is a direct set_state call, where a
message id is not enough to name a row: a retry is the same message re-enqueued,
so an id can match several rows at once and nothing in such a call says which one
the status belongs to.

So drop patch_headers and raise NotImplementedError when no message is given.
This also drops, as dead code, the transaction routing added in 6aa8d69 and the
broker.queues check added in e9eb62c -- both of which guarded that path, and both
of which are what made it clear the path had no caller worth keeping. And it
drops the (message->>'message_id') index, whose only reader was patch_headers:
one less index to maintain on every insert into the throughput-critical table.
Comment thread docs/source/changelog.rst Outdated
Comment thread docs/source/changelog.rst Outdated
Comment thread docs/source/guide.rst
Comment thread remoulade/brokers/postgres.py Outdated
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/state/backends/redis.py
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/helpers/postgres_client.py Outdated
Comment thread remoulade/helpers/postgres_client.py
Comment thread remoulade/brokers/postgres.py Outdated
Comment thread remoulade/helpers/postgres_client.py Outdated
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/state/backends/postgres.py
PostgresBackend recorded a status without ever running a statement: it staged a
jsonb patch on the in-flight message, and the broker folded it into the archive
it performs on ack/nack anyway. That coupling is not worth what it saves.

The status is now an UPDATE on pgmq.q_<queue>, run at set_state time -- one
statement per processed message, on top of the ack. The hook order allows it:
emit_after("process_message") runs before post_process_message, so the row is
still enqueued when the terminal status is written, and pgmq.archive carries the
headers over. A status now also survives an archive that failed and got
swallowed, where a staged patch was lost with it.

The row is named by State.delivery_id, a new field the state middleware fills
from the in-flight message (MessageProxy.delivery_id, None on the base class and
the PGMQ msg_id on PostgresMessage). Carrying the id in the State rather than as
a set_state kwarg gives StateBackend.set_state its signature back, so this branch
no longer breaks a state backend defined out of tree. A state without a
delivery_id records nothing, which is the failed-enqueue case: MessageState
reports a Failure, but an enqueue that raised wrote no row to record it on -- so
the NotImplementedError 6396c1f introduced for that goes away with it.

Targeting stays on msg_id rather than (message->>'message_id'): a retry keeps the
message_id, so two rows of a queue can share it, and the index such a statement
would need was dropped in 6396c1f.

The broker loses PostgresMessage.stage_headers, its _header_patch and the archive
override that merged them, so it knows nothing of the state backend any more and
_archive_message is a plain pgmq.archive again.
The Unreleased section had grown a bullet per commit, in Upgrading/Feat/Changed,
several of which described the same change from different angles. Release it as
7.1.0 with one bullet per change a reader upgrading from 7.0.0 can act on.

Folded the msg_id index backfill and its table-lock warning into a single Fix
bullet, moved the queue name validation to Breaking changes, where it belongs
since a name over 47 characters used to be accepted, and dropped the move of the
broker's SQL into RemouladePostgresClient: internal, with nothing for a reader to
do about it.
It is the only method added on this branch that implements a base class one
without saying so. Everything else either overrides nothing (patch_headers,
create_indexes, _connection, _run, PostgresBackend.client, _build_message) or is
already decorated (__next__, ack, nack, requeue, close, delivery_id).
@mducros-wm
mducros-wm force-pushed the feat/pgmq-state-backend branch from 5b4a9bf to d187cba Compare August 24, 2026 09:34
@mducros-wm
mducros-wm requested a review from fregogui August 24, 2026 14:14
Comment thread remoulade/state/backends/postgres.py Outdated
Comment thread remoulade/brokers/postgres.py
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.

2 participants