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.
- {txoutbox-0.2.2 → txoutbox-0.2.3}/.gitignore +2 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/CHANGELOG.md +34 -1
- {txoutbox-0.2.2 → txoutbox-0.2.3}/PKG-INFO +3 -2
- {txoutbox-0.2.2 → txoutbox-0.2.3}/README.md +2 -1
- {txoutbox-0.2.2 → txoutbox-0.2.3}/pyproject.toml +12 -2
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/postgres.py +9 -2
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/relay.py +19 -6
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_postgres.py +43 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_relay.py +74 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/LICENSE +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/README.md +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/__init__.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/models.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/examples/django_outbox/storage.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/__init__.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/__init__.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/memory.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/adapters/sqlite.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/backoff.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/message.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/poller.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/protocols.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/py.typed +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/src/txoutbox/testing.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/conftest.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_django_example.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_memory.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_primitives.py +0 -0
- {txoutbox-0.2.2 → txoutbox-0.2.3}/tests/test_sqlite.py +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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'
|
|
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): #
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|