Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ Changelog

All notable changes to this project will be documented in this file.

`7.1.1`_ -- 2026-09-02
----------------------
Fix
^^^
* ``MessageState`` no longer skips every write when it has no ``state_ttl``: the ttl is now the state backend's
business, through the new ``StateBackend.requires_ttl`` class attribute. It stays true for ``RedisBackend`` and
``StubBackend``, whose retention the ttl really does bound, and is false for ``PostgresBackend``, whose retention
is the pgmq archive's. Recording states with ``PostgresBackend`` no longer requires passing a ttl it ignores.
* ``MessageState.__init__`` is annotated, and its ``state_ttl`` typed ``int | None`` as it already accepted.

`7.1.0`_ -- 2026-08-21
----------------------
Feat
Expand Down
7 changes: 4 additions & 3 deletions docs/source/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,10 @@ This backend is **write-only**: ``get_state``, ``get_states``,

Two differences with the Redis state backend are worth planning for:

* ``state_ttl`` is ignored. Retention is the archive's, driven by
``archive_retention_interval_in_days`` and ``pg_partman``: a status is gone once
the partition holding its message is dropped. Purging or dropping a queue
* ``state_ttl`` is ignored, and leaving it unset does not turn state tracking off the
way it does for a backend whose retention it bounds. Retention is the archive's,
driven by ``archive_retention_interval_in_days`` and ``pg_partman``: a status is gone
once the partition holding its message is dropped. Purging or dropping a queue
destroys the statuses too.
* A status cannot outlive its message, since the backend keeps no store of its own.

Expand Down
7 changes: 7 additions & 0 deletions remoulade/state/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys
from collections import namedtuple
from enum import Enum
from typing import ClassVar

from dateutil.parser import parse

Expand Down Expand Up @@ -132,9 +133,15 @@ class StateBackend:
result data. Defaults to :class:`.JSONEncoder`.
max_size(int): Maximum size of arguments allow to storage
in the database, default 2MB

Attributes:
requires_ttl(bool): Whether ``set_state``'s ``ttl`` is what bounds how long a
state is kept. False for a backend with a retention of its own, which
:class:`.MessageState` must then not gate on a ttl the backend has no say over.
"""

namespace = "remoulade-state*"
requires_ttl: ClassVar[bool] = True

def __init__(self, *, namespace: str = "remoulade-state", encoder: Encoder = None, max_size=2e6):
from ..message import get_encoder
Expand Down
4 changes: 3 additions & 1 deletion remoulade/state/backends/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"""A state backend that records a message's status inside the pgmq message itself."""

import json
from typing import Final, override
from typing import ClassVar, Final, override

from ...broker import Broker
from ...encoder import Encoder
Expand Down Expand Up @@ -65,6 +65,8 @@ class PostgresBackend(StateBackend):
status alone never comes close to the default.
"""

requires_ttl: ClassVar[bool] = False

def __init__(
self,
broker: Broker,
Expand Down
11 changes: 6 additions & 5 deletions remoulade/state/middleware.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
from datetime import UTC, datetime

from ..middleware import Middleware
from .backend import State, StateStatusesEnum
from .backend import State, StateBackend, StateStatusesEnum


class MessageState(Middleware):
"""Middleware use to storage and update the state
of the messages.
Parameters
state_ttl(int):Time(seconds) that the state will be storage
in the database
state_ttl(int | None):Time(seconds) that the state will be storage
in the database. None, or anything not positive, turns state
tracking off, unless the backend owns its retention.
"""

def __init__(self, backend, state_ttl=3600):
def __init__(self, backend: StateBackend, state_ttl: int | None = 3600) -> None:
self.backend = backend
self.state_ttl = state_ttl

def save(self, message, status, priority=None, **kwargs):
if self.state_ttl is None or self.state_ttl <= 0:
if self.backend.requires_ttl and (self.state_ttl is None or self.state_ttl <= 0):
return
args = message.args
options = message.options
Expand Down
11 changes: 11 additions & 0 deletions tests/middleware/test_message_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,14 @@ def test_a_message_without_a_delivery_carries_no_delivery_id(self, stub_broker,
middleware.before_enqueue(stub_broker, do_work.message(), 0)

assert backend.set_state.call_args.args[0].delivery_id is None

@pytest.mark.parametrize("state_ttl", [None, 0, -1])
def test_a_backend_owning_its_retention_stores_without_a_state_ttl(self, stub_broker, do_work, state_ttl):
"""A backend ignoring the ttl must not need a meaningless one to record anything."""
backend = Mock(requires_ttl=False)
stub_broker.add_middleware(MessageState(backend=backend, state_ttl=state_ttl))

do_work.send()

assert backend.set_state.call_count == 1
assert backend.set_state.call_args.args[1] == state_ttl
Loading