txoutbox 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {txoutbox-0.2.2 → txoutbox-0.2.3}/.gitignore +2 -0
  2. {txoutbox-0.2.2 → txoutbox-0.2.3}/CHANGELOG.md +34 -1
  3. {txoutbox-0.2.2 → txoutbox-0.2.3}/PKG-INFO +3 -2
  4. {txoutbox-0.2.2 → txoutbox-0.2.3}/README.md +2 -1
  5. {txoutbox-0.2.2 → txoutbox-0.2.3}/pyproject.toml +12 -2
  6. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/postgres.py +9 -2
  7. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/relay.py +19 -6
  8. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_postgres.py +43 -0
  9. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_relay.py +74 -0
  10. {txoutbox-0.2.2 → txoutbox-0.2.3}/LICENSE +0 -0
  11. {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/README.md +0 -0
  12. {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/__init__.py +0 -0
  13. {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/models.py +0 -0
  14. {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/storage.py +0 -0
  15. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/__init__.py +0 -0
  16. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/__init__.py +0 -0
  17. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/memory.py +0 -0
  18. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/sqlite.py +0 -0
  19. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/backoff.py +0 -0
  20. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/message.py +0 -0
  21. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/poller.py +0 -0
  22. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/protocols.py +0 -0
  23. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/py.typed +0 -0
  24. {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/testing.py +0 -0
  25. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/conftest.py +0 -0
  26. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_django_example.py +0 -0
  27. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_memory.py +0 -0
  28. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_primitives.py +0 -0
  29. {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_sqlite.py +0 -0
@@ -19,3 +19,5 @@ wheels/
19
19
  *.db-wal
20
20
  *.db-shm
21
21
  pgdata/
22
+ .coverage
23
+ coverage.xml
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.3] - 2026-11-04
11
+
12
+ ### Fixed
13
+
14
+ - `async with Relay(...)` hung forever when the body returned before the background
15
+ `run()` had started: `run()` reset the stop flag that `__aexit__` had just set.
16
+ `__aexit__` now cancels a relay task that has not started yet; `run()` semantics are
17
+ unchanged.
18
+ - `PostgresStorage` with `visibility_delay` and strict ordering could publish messages
19
+ of one key out of order: `created_at` defaulted to `now()` (transaction start), so a
20
+ lower id could be younger than a higher one, and the ordering predicate did not hold
21
+ a row back behind an earlier row that was still too young. Both are fixed: the
22
+ predicate checks the age of earlier rows, and the schema uses `clock_timestamp()`.
23
+ - `run(handle_signals=True)` raised `NotImplementedError` on exit on Windows, where the
24
+ event loop cannot install signal handlers: it tried to remove handlers it never added.
25
+
26
+ ### Changed
27
+
28
+ - CI: Postgres 14/17/18, macOS and Windows, lowest direct dependency versions, branch
29
+ coverage gate, tests against the built wheel, workflow linting (actionlint, zizmor),
30
+ actions pinned by SHA with Dependabot. Releases now require green CI, a tag on `main`
31
+ and a changelog entry.
32
+
33
+ ### Upgrading
34
+
35
+ - Tables created by earlier versions default `created_at` to `now()`. With that
36
+ default the predicate fix is not enough: a row whose transaction has not committed
37
+ is invisible to the check, so `visibility_delay` would have to exceed twice your
38
+ longest producer transaction. `schema_sql()` / `create_schema()` now end with
39
+ `ALTER TABLE ... ALTER COLUMN created_at SET DEFAULT clock_timestamp()` (a brief
40
+ metadata-only lock); run `create_schema()` once or add that statement to a migration.
41
+
10
42
  ## [0.2.2] - 2026-09-12
11
43
 
12
44
  ### Fixed
@@ -125,7 +157,8 @@ guarantees were fixed instead of patched.
125
157
  `Storage` implementation against the relay's expectations, including lease
126
158
  expiry, concurrent claims and strict per-key ordering.
127
159
 
128
- [Unreleased]: https://github.com/OsmnvAslan/txoutbox/compare/v0.2.2...HEAD
160
+ [Unreleased]: https://github.com/OsmnvAslan/txoutbox/compare/v0.2.3...HEAD
161
+ [0.2.3]: https://github.com/OsmnvAslan/txoutbox/compare/v0.2.2...v0.2.3
129
162
  [0.2.2]: https://github.com/OsmnvAslan/txoutbox/compare/v0.2.1...v0.2.2
130
163
  [0.2.1]: https://github.com/OsmnvAslan/txoutbox/compare/v0.2.0...v0.2.1
131
164
  [0.2.0]: https://github.com/OsmnvAslan/txoutbox/compare/v0.1.0...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: txoutbox
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Storage-agnostic Transactional Outbox relay for asyncio: bring your own table, bring your own broker.
5
5
  Project-URL: Homepage, https://github.com/OsmnvAslan/txoutbox
6
6
  Project-URL: Repository, https://github.com/OsmnvAslan/txoutbox
@@ -221,7 +221,8 @@ RelayConfig(
221
221
  * **Insertion order is id order, not commit order.** On Postgres a `BIGSERIAL` id is assigned at `INSERT`;
222
222
  a transaction with a smaller id can commit later and be picked up after a larger one. If that matters
223
223
  for your keys, set `PostgresStorage(visibility_delay=timedelta(seconds=2))` to only claim rows older than
224
- your longest producer transaction.
224
+ your longest producer transaction. Age is measured from `created_at`, which the schema sets to the
225
+ insert time (`clock_timestamp()`); for tables created before 0.2.3, `create_schema()` switches the default.
225
226
  * **Leases bound duplicates.** A round must finish inside its lease. `RelayConfig` warns at construction
226
227
  when `ceil(batch_size / concurrency) * publish_timeout` exceeds `lease`, and the relay warns after the
227
228
  fact when a round ran long. Leave `publish_timeout` on.
@@ -192,7 +192,8 @@ RelayConfig(
192
192
  * **Insertion order is id order, not commit order.** On Postgres a `BIGSERIAL` id is assigned at `INSERT`;
193
193
  a transaction with a smaller id can commit later and be picked up after a larger one. If that matters
194
194
  for your keys, set `PostgresStorage(visibility_delay=timedelta(seconds=2))` to only claim rows older than
195
- your longest producer transaction.
195
+ your longest producer transaction. Age is measured from `created_at`, which the schema sets to the
196
+ insert time (`clock_timestamp()`); for tables created before 0.2.3, `create_schema()` switches the default.
196
197
  * **Leases bound duplicates.** A round must finish inside its lease. `RelayConfig` warns at construction
197
198
  when `ceil(batch_size / concurrency) * publish_timeout` exceeds `lease`, and the relay warns after the
198
199
  fact when a round ran long. Leave `publish_timeout` on.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "txoutbox"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Storage-agnostic Transactional Outbox relay for asyncio: bring your own table, bring your own broker."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -42,6 +42,7 @@ dev = [
42
42
  "asyncpg>=0.31.0",
43
43
  "pgserver>=0.1.4; python_version < '3.13'",
44
44
  "pytest-timeout>=2.3",
45
+ "pytest-cov>=7.1.0",
45
46
  ]
46
47
  examples = [
47
48
  "django>=5.0",
@@ -57,13 +58,22 @@ build-backend = "hatchling.build"
57
58
  packages = ["src/txoutbox"]
58
59
 
59
60
  [tool.hatch.build.targets.sdist]
60
- exclude = [".github", ".python-version", "uv.lock"]
61
+ exclude = [".github", ".python-version", "uv.lock", "CLAUDE.md"]
61
62
 
62
63
  [tool.pytest.ini_options]
63
64
  asyncio_mode = "auto"
64
65
  asyncio_default_fixture_loop_scope = "function"
65
66
  testpaths = ["tests"]
66
67
 
68
+ [tool.coverage.run]
69
+ source = ["txoutbox"]
70
+ branch = true
71
+
72
+ [tool.coverage.report]
73
+ show_missing = true
74
+ skip_covered = true
75
+ exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
76
+
67
77
  [tool.ruff]
68
78
  line-length = 100
69
79
  target-version = "py311"
@@ -39,10 +39,12 @@ CREATE TABLE IF NOT EXISTS {table} (
39
39
  leased_by TEXT,
40
40
  lease_until TIMESTAMPTZ,
41
41
  last_error TEXT,
42
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
42
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
43
43
  );
44
44
  CREATE INDEX IF NOT EXISTS {name}_pending_idx ON {table} (id) WHERE status = 'pending';
45
45
  CREATE INDEX IF NOT EXISTS {name}_key_pending_idx ON {table} (key, id) WHERE status = 'pending';
46
+ -- Tables created before 0.2.3 defaulted to now(), the transaction start (metadata-only change).
47
+ ALTER TABLE {table} ALTER COLUMN created_at SET DEFAULT clock_timestamp();
46
48
  """
47
49
 
48
50
  NOTIFY_SQL = """
@@ -77,6 +79,9 @@ class PostgresStorage:
77
79
  are assigned at insert time, not commit time, so a long transaction can commit a
78
80
  lower id *after* a higher one was already delivered. A delay of a few seconds
79
81
  (longer than your slowest producing transaction) makes that reordering impossible.
82
+ Age is measured from ``created_at``, which must be the insert time
83
+ (``clock_timestamp()``), not the transaction start (``now()``); with strict
84
+ ordering, a row is also held back while an earlier row with its key is too young.
80
85
  * ``notify_channel`` adds an ``AFTER INSERT`` trigger to :meth:`schema_sql` that
81
86
  calls ``pg_notify``; pair it with :meth:`listen` to wake the relay instantly.
82
87
  """
@@ -403,10 +408,12 @@ class _Sql:
403
408
  """Statements pre-rendered for one table."""
404
409
 
405
410
  def __init__(self, table: str, strict_ordering: bool, visibility_delay: bool) -> None:
411
+ young = " OR p.created_at > now() - $4::interval" if visibility_delay else ""
406
412
  blocked = (
407
413
  " AND (o.key IS NULL OR NOT EXISTS ("
408
414
  f"SELECT 1 FROM {table} p WHERE p.key = o.key AND p.id < o.id"
409
- " AND p.status = 'pending' AND (p.retry_at > now() OR p.lease_until > now())))"
415
+ " AND p.status = 'pending'"
416
+ f" AND (p.retry_at > now() OR p.lease_until > now(){young})))"
410
417
  if strict_ordering
411
418
  else ""
412
419
  )
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import inspect
6
7
  import logging
7
8
  import math
8
9
  import os
@@ -10,7 +11,7 @@ import signal
10
11
  import socket
11
12
  import time
12
13
  import uuid
13
- from collections.abc import Awaitable, Callable, Sequence
14
+ from collections.abc import Awaitable, Callable, Coroutine, Sequence
14
15
  from dataclasses import dataclass, field
15
16
  from datetime import UTC, datetime, timedelta
16
17
  from types import TracebackType
@@ -190,11 +191,13 @@ class Relay:
190
191
 
191
192
  if handle_signals:
192
193
  for sig in (signal.SIGINT, signal.SIGTERM):
194
+ current = signal.getsignal(sig)
193
195
  try:
194
- previous[sig] = signal.getsignal(sig)
195
196
  loop.add_signal_handler(sig, on_signal)
196
- except (NotImplementedError, RuntimeError): # pragma: no cover - Windows
197
+ except (NotImplementedError, RuntimeError): # Windows
197
198
  log.warning("cannot install handler for %s", sig.name)
199
+ continue
200
+ previous[sig] = current
198
201
  try:
199
202
  while not self._stopping:
200
203
  try:
@@ -244,9 +247,19 @@ class Relay:
244
247
  tb: TracebackType | None,
245
248
  ) -> None:
246
249
  self.stop()
247
- if self._task is not None:
248
- await self._task
249
- self._task = None
250
+ task, self._task = self._task, None
251
+ if task is None:
252
+ return
253
+ coro = task.get_coro()
254
+ if isinstance(coro, Coroutine) and inspect.getcoroutinestate(coro) == inspect.CORO_CREATED:
255
+ # run() has not started, so it would reset the stop flag; there is no round to finish.
256
+ task.cancel()
257
+ try:
258
+ await task # not asyncio.wait: cancelling __aexit__ must reach the relay task
259
+ except asyncio.CancelledError:
260
+ current = asyncio.current_task()
261
+ if current is not None and current.cancelling():
262
+ raise
250
263
 
251
264
  # -- one round ---------------------------------------------------------------------
252
265
 
@@ -239,6 +239,49 @@ async def test_visibility_delay(pool) -> None:
239
239
  await _drop(pool, storage)
240
240
 
241
241
 
242
+ async def test_visibility_delay_holds_back_successors_of_a_young_row(pool) -> None:
243
+ # A lower id can be younger than a higher one (rows from a table created before
244
+ # 0.2.3 used now(), the transaction start). The older row must still wait.
245
+ # A full second before the young row is visible keeps this stable on slow CI.
246
+ storage = await _make(pool, visibility_delay=timedelta(seconds=1))
247
+ t = storage.qualified_table
248
+ await pool.execute(f"INSERT INTO {t} (topic, key, payload) VALUES ('t', 'k', 'young')")
249
+ await pool.execute(
250
+ f"INSERT INTO {t} (topic, key, payload, created_at)"
251
+ " VALUES ('t', 'k', 'old', now() - interval '5 seconds')"
252
+ )
253
+ assert list(await storage.claim(batch_size=10, lease=LEASE, worker_id="w")) == []
254
+ await asyncio.sleep(1.1)
255
+ got = await storage.claim(batch_size=10, lease=LEASE, worker_id="w")
256
+ assert [m.payload for m in got] == [b"young", b"old"]
257
+ await _drop(pool, storage)
258
+
259
+
260
+ async def test_created_at_is_the_insert_time_not_the_transaction_start(pool) -> None:
261
+ storage = await _make(pool)
262
+ async with pool.acquire() as conn, conn.transaction():
263
+ await storage.insert(conn, "t", b"first")
264
+ await asyncio.sleep(0.05)
265
+ await storage.insert(conn, "t", b"second")
266
+ got = await storage.claim(batch_size=10, lease=LEASE, worker_id="w")
267
+ assert got[1].created_at > got[0].created_at # type: ignore[operator]
268
+ await _drop(pool, storage)
269
+
270
+
271
+ async def test_create_schema_upgrades_a_pre_0_2_3_created_at_default(pool) -> None:
272
+ storage = await _make(pool)
273
+ t = storage.qualified_table
274
+ await pool.execute(f"ALTER TABLE {t} ALTER COLUMN created_at SET DEFAULT now()")
275
+ await storage.create_schema()
276
+ default = await pool.fetchval(
277
+ "SELECT column_default FROM information_schema.columns"
278
+ " WHERE table_name = $1 AND column_name = 'created_at'",
279
+ storage.table,
280
+ )
281
+ assert default == "clock_timestamp()"
282
+ await _drop(pool, storage)
283
+
284
+
242
285
  def test_visibility_delay_validation() -> None:
243
286
  with pytest.raises(ValueError):
244
287
  PostgresStorage(object(), visibility_delay=timedelta(seconds=-1)) # type: ignore[arg-type]
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import signal
3
+ import sys
3
4
  import time
4
5
  from datetime import UTC, datetime, timedelta
5
6
 
@@ -8,6 +9,8 @@ import pytest
8
9
  from txoutbox import Backoff, Hooks, OutboxMessage, Relay, RelayConfig
9
10
  from txoutbox.adapters.memory import MemoryPublisher, MemoryStorage, Status
10
11
 
12
+ posix_signals = pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
13
+
11
14
 
12
15
  def cfg(**kw) -> RelayConfig:
13
16
  base = dict(poll_min=0.01, poll_max=0.05, backoff=Backoff(base=timedelta(seconds=10), jitter=0))
@@ -198,6 +201,56 @@ async def test_context_manager_and_wake() -> None:
198
201
  assert len(publisher.published) == 1
199
202
 
200
203
 
204
+ async def test_context_manager_exits_when_the_body_returns_at_once() -> None:
205
+ # stop() from __aexit__ lands before the background run() has started.
206
+ async with Relay(MemoryStorage(), MemoryPublisher(), config=cfg()):
207
+ pass
208
+
209
+
210
+ async def test_cancelling_context_exit_cancels_the_relay_task() -> None:
211
+ storage = MemoryStorage()
212
+ await storage.add("t", b"x")
213
+ relay = Relay(storage, MemoryPublisher(delay=5), config=cfg())
214
+
215
+ async def app() -> None:
216
+ async with relay:
217
+ await asyncio.sleep(0.05) # a round with a slow publish is in flight
218
+
219
+ outer = asyncio.create_task(app())
220
+ await asyncio.sleep(0.02)
221
+ (inner,) = [t for t in asyncio.all_tasks() if t.get_name().startswith("txoutbox-relay")]
222
+ await asyncio.sleep(0.08) # outer is inside __aexit__, waiting for the round
223
+ outer.cancel()
224
+ with pytest.raises(asyncio.CancelledError):
225
+ await outer
226
+ assert inner.cancelled()
227
+
228
+
229
+ async def test_stop_after_a_cancelled_run_does_not_stick() -> None:
230
+ storage, publisher = MemoryStorage(), MemoryPublisher()
231
+ relay = Relay(storage, publisher, config=cfg())
232
+ task = asyncio.create_task(relay.run())
233
+ await asyncio.sleep(0.02)
234
+ task.cancel()
235
+ with pytest.raises(asyncio.CancelledError):
236
+ await task
237
+ relay.stop() # a late shutdown hook
238
+ await storage.add("t", b"x")
239
+ task = asyncio.create_task(relay.run())
240
+ while not publisher.published:
241
+ await asyncio.sleep(0.005)
242
+ relay.stop()
243
+ await asyncio.wait_for(task, 1)
244
+
245
+
246
+ async def test_stop_ends_every_loop_sharing_a_relay() -> None:
247
+ relay = Relay(MemoryStorage(), MemoryPublisher(), config=cfg())
248
+ loops = [asyncio.create_task(relay.run()) for _ in range(2)]
249
+ await asyncio.sleep(0.02)
250
+ relay.stop()
251
+ await asyncio.wait_for(asyncio.gather(*loops), 1)
252
+
253
+
201
254
  async def test_storage_errors_do_not_kill_the_loop() -> None:
202
255
  calls = 0
203
256
 
@@ -245,6 +298,7 @@ async def test_hook_exceptions_are_swallowed() -> None:
245
298
  assert result.published == 1
246
299
 
247
300
 
301
+ @posix_signals
248
302
  async def test_signal_handler_stops_run() -> None:
249
303
  storage, publisher = MemoryStorage(), MemoryPublisher()
250
304
  relay = Relay(storage, publisher, config=cfg())
@@ -352,6 +406,7 @@ async def test_on_round_hook_and_failure_summary(caplog: pytest.LogCaptureFixtur
352
406
  logging.getLogger("txoutbox").setLevel(logging.CRITICAL)
353
407
 
354
408
 
409
+ @posix_signals
355
410
  async def test_second_signal_cancels_the_round_and_returns_quietly() -> None:
356
411
  storage = MemoryStorage()
357
412
  await storage.add("t", b"x")
@@ -366,6 +421,7 @@ async def test_second_signal_cancels_the_round_and_returns_quietly() -> None:
366
421
  assert signal.getsignal(signal.SIGTERM) is signal.SIG_DFL
367
422
 
368
423
 
424
+ @posix_signals
369
425
  async def test_plain_signal_handler_is_restored() -> None:
370
426
  hits: list[int] = []
371
427
  previous = signal.signal(signal.SIGTERM, lambda signum, frame: hits.append(signum))
@@ -382,6 +438,7 @@ async def test_plain_signal_handler_is_restored() -> None:
382
438
  signal.signal(signal.SIGTERM, previous)
383
439
 
384
440
 
441
+ @posix_signals
385
442
  async def test_loop_signal_handler_is_not_clobbered_into_a_noop() -> None:
386
443
  """We cannot restore a loop-level handler, but we must not leave asyncio's C stub behind."""
387
444
  loop = asyncio.get_running_loop()
@@ -450,6 +507,7 @@ async def test_stop_cuts_the_storage_error_pause_short() -> None:
450
507
  await asyncio.wait_for(task, 1)
451
508
 
452
509
 
510
+ @posix_signals
453
511
  async def test_two_signals_during_storage_error_pause_return_quietly() -> None:
454
512
  relay = Relay(_DownStorage(), MemoryPublisher(), config=cfg(storage_error_delay=30))
455
513
  task = asyncio.create_task(relay.run(handle_signals=True))
@@ -467,3 +525,19 @@ async def test_external_cancellation_still_propagates_from_the_pause() -> None:
467
525
  task.cancel()
468
526
  with pytest.raises(asyncio.CancelledError):
469
527
  await task
528
+
529
+
530
+ async def test_handle_signals_without_loop_support_runs_and_exits(
531
+ monkeypatch: pytest.MonkeyPatch,
532
+ ) -> None:
533
+ # Windows' ProactorEventLoop raises NotImplementedError for both calls.
534
+ loop = asyncio.get_running_loop()
535
+
536
+ def unsupported(*args: object) -> None:
537
+ raise NotImplementedError
538
+
539
+ monkeypatch.setattr(loop, "add_signal_handler", unsupported)
540
+ monkeypatch.setattr(loop, "remove_signal_handler", unsupported)
541
+ relay = Relay(MemoryStorage(), MemoryPublisher(), config=cfg())
542
+ loop.call_later(0.05, relay.stop)
543
+ await asyncio.wait_for(relay.run(handle_signals=True), 1)
File without changes
File without changes
File without changes
File without changes
File without changes