idemkit 0.1.0__py3-none-any.whl
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.
- idemkit/__init__.py +101 -0
- idemkit/_version.py +1 -0
- idemkit/adapters/__init__.py +10 -0
- idemkit/adapters/ai.py +723 -0
- idemkit/adapters/asgi.py +533 -0
- idemkit/adapters/queue.py +413 -0
- idemkit/adapters/route.py +273 -0
- idemkit/adapters/wsgi.py +393 -0
- idemkit/backends/__init__.py +30 -0
- idemkit/backends/base.py +116 -0
- idemkit/backends/dynamodb.py +489 -0
- idemkit/backends/memory.py +325 -0
- idemkit/backends/mongo.py +395 -0
- idemkit/backends/postgres.py +682 -0
- idemkit/backends/redis.py +547 -0
- idemkit/cli.py +165 -0
- idemkit/conformance/__init__.py +25 -0
- idemkit/conformance/backend.py +257 -0
- idemkit/conformance/report.py +54 -0
- idemkit/contrib/__init__.py +30 -0
- idemkit/contrib/drf.py +181 -0
- idemkit/contrib/fastapi.py +141 -0
- idemkit/contrib/kafka.py +96 -0
- idemkit/contrib/logging.py +61 -0
- idemkit/contrib/mcp.py +113 -0
- idemkit/contrib/prometheus.py +79 -0
- idemkit/contrib/pubsub.py +98 -0
- idemkit/contrib/rabbitmq.py +138 -0
- idemkit/contrib/reconciliation.py +86 -0
- idemkit/contrib/sqs.py +117 -0
- idemkit/core/__init__.py +36 -0
- idemkit/core/codecs.py +229 -0
- idemkit/core/config.py +255 -0
- idemkit/core/engine.py +331 -0
- idemkit/core/events.py +63 -0
- idemkit/core/exception_cache.py +88 -0
- idemkit/core/exceptions.py +79 -0
- idemkit/core/fingerprint.py +153 -0
- idemkit/core/policy.py +143 -0
- idemkit/core/runner.py +664 -0
- idemkit/core/state.py +95 -0
- idemkit/core/sync_bridge.py +115 -0
- idemkit/problem_details.py +119 -0
- idemkit/py.typed +0 -0
- idemkit-0.1.0.dist-info/METADATA +446 -0
- idemkit-0.1.0.dist-info/RECORD +48 -0
- idemkit-0.1.0.dist-info/WHEEL +4 -0
- idemkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Google Cloud Pub/Sub glue for idemkit's queue surface.
|
|
2
|
+
|
|
3
|
+
Pub/Sub is at-least-once: if an ack doesn't reach the server before the ack
|
|
4
|
+
deadline (or you nack), the message is redelivered. Even with exactly-once
|
|
5
|
+
delivery enabled it only dedups *subscriber-side* redeliveries — a publisher that
|
|
6
|
+
retries a lost publish produces a new ``message_id``, so a duplicate can still
|
|
7
|
+
reach you. :func:`pubsub_consumer` presets an
|
|
8
|
+
:class:`~idemkit.IdempotentConsumer` so the side effect runs once per message.
|
|
9
|
+
|
|
10
|
+
Two Pub/Sub specifics:
|
|
11
|
+
|
|
12
|
+
* **Dedup id = ``message_id``.** Stable across subscriber redeliveries of the same
|
|
13
|
+
message. It does *not* cover publisher-side duplicates (those carry different
|
|
14
|
+
ids); if your producer can double-publish, dedup on a business id in the payload
|
|
15
|
+
instead via a custom ``QueueConfig.dedup_id``.
|
|
16
|
+
* **Visibility-timeout analogue = the ack deadline.** The lease is derived from it
|
|
17
|
+
(``ack_deadline_seconds``); for slow handlers, extend the deadline with the
|
|
18
|
+
client's lease-management or raise it on the subscription.
|
|
19
|
+
|
|
20
|
+
Streaming pull runs your callback in a background thread per message. Wrap it with
|
|
21
|
+
:func:`pubsub_callback`: it dispatches through idemkit and acks on ``ACK`` / nacks
|
|
22
|
+
on ``RETRY``.
|
|
23
|
+
|
|
24
|
+
Example (google-cloud-pubsub)::
|
|
25
|
+
|
|
26
|
+
from google.cloud import pubsub_v1
|
|
27
|
+
from idemkit import RedisBackend
|
|
28
|
+
from idemkit.contrib.pubsub import pubsub_consumer, pubsub_callback
|
|
29
|
+
|
|
30
|
+
consumer = pubsub_consumer(
|
|
31
|
+
backend=RedisBackend.from_url("redis://localhost:6379"),
|
|
32
|
+
ack_deadline_seconds=60,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@consumer.handle
|
|
37
|
+
def process(message) -> None:
|
|
38
|
+
charge_customer(message.data) # runs once per message_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
subscriber = pubsub_v1.SubscriberClient()
|
|
42
|
+
subscriber.subscribe(subscription_path, callback=pubsub_callback(consumer))
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import dataclasses
|
|
48
|
+
from collections.abc import Callable
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from idemkit.adapters.queue import ConsumerAction, IdempotentConsumer
|
|
52
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
53
|
+
from idemkit.core.policy import QueueConfig
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pubsub_dedup_id(message: Any) -> str:
|
|
57
|
+
"""The Pub/Sub ``message_id`` (the dedup id)."""
|
|
58
|
+
return str(message.message_id)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pubsub_consumer(
|
|
62
|
+
*,
|
|
63
|
+
backend: IdempotencyBackend,
|
|
64
|
+
ack_deadline_seconds: float = 60.0,
|
|
65
|
+
config: QueueConfig | None = None,
|
|
66
|
+
) -> IdempotentConsumer:
|
|
67
|
+
"""Build an :class:`~idemkit.IdempotentConsumer` wired for Pub/Sub messages.
|
|
68
|
+
|
|
69
|
+
Presets the dedup id (``message_id``) and the lease (from the ack deadline).
|
|
70
|
+
Pass a :class:`~idemkit.QueueConfig` for behaviour (``max_attempts``,
|
|
71
|
+
``on_exhausted``, ``scope`` per subscription, ...).
|
|
72
|
+
"""
|
|
73
|
+
cfg = config or QueueConfig()
|
|
74
|
+
cfg = dataclasses.replace(
|
|
75
|
+
cfg,
|
|
76
|
+
dedup_id=pubsub_dedup_id,
|
|
77
|
+
visibility_timeout_seconds=ack_deadline_seconds,
|
|
78
|
+
)
|
|
79
|
+
return IdempotentConsumer(backend=backend, config=cfg)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pubsub_callback(consumer: IdempotentConsumer) -> Callable[[Any], None]:
|
|
83
|
+
"""Wrap ``consumer`` as a streaming-pull callback that acks/nacks the message.
|
|
84
|
+
|
|
85
|
+
Returns a function you pass to ``subscriber.subscribe(..., callback=...)``. It
|
|
86
|
+
dispatches the message through idemkit (``dispatch_sync``, since the callback
|
|
87
|
+
runs on the client's thread) and calls ``message.ack()`` on ``ACK`` or
|
|
88
|
+
``message.nack()`` on ``RETRY`` (redeliver after the ack deadline).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def _callback(message: Any) -> None:
|
|
92
|
+
result = consumer.dispatch_sync(message)
|
|
93
|
+
if result.action is ConsumerAction.ACK:
|
|
94
|
+
message.ack()
|
|
95
|
+
else: # RETRY
|
|
96
|
+
message.nack()
|
|
97
|
+
|
|
98
|
+
return _callback
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""RabbitMQ (pika) glue for idemkit's queue surface.
|
|
2
|
+
|
|
3
|
+
RabbitMQ is at-least-once under manual acks: an unacked delivery is requeued when
|
|
4
|
+
the channel or connection drops, and you can requeue explicitly with ``basic_nack``.
|
|
5
|
+
So a consumer sees the same message again after a network blip, a crash, or a
|
|
6
|
+
transient failure. :func:`rabbitmq_consumer` presets an
|
|
7
|
+
:class:`~idemkit.IdempotentConsumer` so the side effect runs once per message even
|
|
8
|
+
across those redeliveries.
|
|
9
|
+
|
|
10
|
+
Two things are different from SQS/Kafka, and worth stating plainly:
|
|
11
|
+
|
|
12
|
+
* **There is no dedup id built in.** RabbitMQ does not stamp a stable per-message
|
|
13
|
+
id for you. The clean choice is the AMQP ``message_id`` property, which the
|
|
14
|
+
*publisher* must set (a UUID, or your own business id). If your producer doesn't
|
|
15
|
+
set one, dedup has nothing to key on — see :class:`RabbitMessage`.
|
|
16
|
+
* **There is no visibility timeout.** An unacked message is not redelivered on a
|
|
17
|
+
timer; it comes back only on nack/requeue or a channel/connection loss. So the
|
|
18
|
+
lease here is just how long idemkit holds the claim before assuming a crash — you
|
|
19
|
+
pick it (``lease_seconds``), it isn't derived from the broker.
|
|
20
|
+
|
|
21
|
+
The ``redelivered`` flag RabbitMQ sets on a re-delivery is carried through on
|
|
22
|
+
:attr:`RabbitMessage.redelivered` if you want to log or branch on it.
|
|
23
|
+
|
|
24
|
+
Example (pika)::
|
|
25
|
+
|
|
26
|
+
import pika
|
|
27
|
+
from idemkit import RedisBackend
|
|
28
|
+
from idemkit.contrib.rabbitmq import rabbitmq_consumer, run_forever
|
|
29
|
+
|
|
30
|
+
consumer = rabbitmq_consumer(
|
|
31
|
+
backend=RedisBackend.from_url("redis://localhost:6379"),
|
|
32
|
+
lease_seconds=300,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@consumer.handle
|
|
37
|
+
def process(msg) -> None:
|
|
38
|
+
charge_customer(msg.body) # runs once per message_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
conn = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
|
|
42
|
+
run_forever(consumer, channel=conn.channel(), queue="charges", lease_seconds=300)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import dataclasses
|
|
48
|
+
from collections.abc import Callable
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from idemkit.adapters.queue import ConsumerAction, IdempotentConsumer
|
|
52
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
53
|
+
from idemkit.core.policy import QueueConfig
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclasses.dataclass(frozen=True)
|
|
57
|
+
class RabbitMessage:
|
|
58
|
+
"""One RabbitMQ delivery, in the shape idemkit dedupes on.
|
|
59
|
+
|
|
60
|
+
``message_id`` is the AMQP ``message_id`` property the publisher set; it is the
|
|
61
|
+
dedup id. ``run_forever`` builds this for you from pika's delivery; if you drive
|
|
62
|
+
the consumer yourself, construct it from ``properties.message_id`` and ``body``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
message_id: str
|
|
66
|
+
body: bytes
|
|
67
|
+
redelivered: bool = False
|
|
68
|
+
delivery_tag: int | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def rabbitmq_dedup_id(message: RabbitMessage) -> str:
|
|
72
|
+
"""The message's AMQP ``message_id`` (the dedup id)."""
|
|
73
|
+
return message.message_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rabbitmq_consumer(
|
|
77
|
+
*,
|
|
78
|
+
backend: IdempotencyBackend,
|
|
79
|
+
lease_seconds: float = 300.0,
|
|
80
|
+
config: QueueConfig | None = None,
|
|
81
|
+
) -> IdempotentConsumer:
|
|
82
|
+
"""Build an :class:`~idemkit.IdempotentConsumer` wired for RabbitMQ deliveries.
|
|
83
|
+
|
|
84
|
+
Presets the dedup id (the AMQP ``message_id``) and the lease (``lease_seconds``,
|
|
85
|
+
since RabbitMQ has no visibility timeout to derive it from). Pass a
|
|
86
|
+
:class:`~idemkit.QueueConfig` for behaviour (``max_attempts``, ``on_exhausted``,
|
|
87
|
+
``scope`` per queue, ...).
|
|
88
|
+
"""
|
|
89
|
+
cfg = config or QueueConfig()
|
|
90
|
+
cfg = dataclasses.replace(
|
|
91
|
+
cfg,
|
|
92
|
+
dedup_id=rabbitmq_dedup_id,
|
|
93
|
+
visibility_timeout_seconds=lease_seconds,
|
|
94
|
+
)
|
|
95
|
+
return IdempotentConsumer(backend=backend, config=cfg)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_forever(
|
|
99
|
+
consumer: IdempotentConsumer,
|
|
100
|
+
*,
|
|
101
|
+
channel: Any,
|
|
102
|
+
queue: str,
|
|
103
|
+
lease_seconds: float = 300.0,
|
|
104
|
+
prefetch: int = 10,
|
|
105
|
+
stop: Callable[[], bool] | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Pull from ``queue`` and dispatch each delivery, acking on ``ACK``.
|
|
108
|
+
|
|
109
|
+
A blocking loop for a synchronous worker (it uses ``dispatch_sync`` and pika's
|
|
110
|
+
``basic_get``). On ``ACK`` the delivery is acked; on ``RETRY`` it is nacked with
|
|
111
|
+
``requeue=True`` so RabbitMQ redelivers it. The publisher MUST set the AMQP
|
|
112
|
+
``message_id`` property; a delivery without one raises ``ValueError`` rather than
|
|
113
|
+
silently skipping dedup. Pass ``stop=lambda: flag`` for graceful shutdown.
|
|
114
|
+
"""
|
|
115
|
+
channel.basic_qos(prefetch_count=prefetch)
|
|
116
|
+
while stop is None or not stop():
|
|
117
|
+
method, properties, body = channel.basic_get(queue=queue, auto_ack=False)
|
|
118
|
+
if method is None: # queue empty
|
|
119
|
+
continue
|
|
120
|
+
message_id = getattr(properties, "message_id", None)
|
|
121
|
+
if not message_id:
|
|
122
|
+
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"idemkit: RabbitMQ delivery has no message_id property to dedup on. "
|
|
125
|
+
"Publish with a message_id (a UUID or your business id), or key on a "
|
|
126
|
+
"header via a custom QueueConfig.dedup_id."
|
|
127
|
+
)
|
|
128
|
+
message = RabbitMessage(
|
|
129
|
+
message_id=message_id,
|
|
130
|
+
body=body,
|
|
131
|
+
redelivered=bool(getattr(method, "redelivered", False)),
|
|
132
|
+
delivery_tag=method.delivery_tag,
|
|
133
|
+
)
|
|
134
|
+
result = consumer.dispatch_sync(message)
|
|
135
|
+
if result.action is ConsumerAction.ACK:
|
|
136
|
+
channel.basic_ack(delivery_tag=method.delivery_tag)
|
|
137
|
+
else: # RETRY: leave it for redelivery
|
|
138
|
+
channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Turn idemkit's uncertain-completion events into a reconciliation signal.
|
|
2
|
+
|
|
3
|
+
Most of the time a key completes cleanly and there is nothing to reconcile. A few
|
|
4
|
+
decisions mean something else happened: the side effect ran, but idemkit could not
|
|
5
|
+
record its result, or ran without protection during a storage outage. In those
|
|
6
|
+
cases the record and reality may disagree, and only the downstream system (your
|
|
7
|
+
payment provider, your mailer) knows the truth.
|
|
8
|
+
|
|
9
|
+
idemkit cannot ask the provider for you. It does not know your business ids and it
|
|
10
|
+
holds only a hash of the key. What it can do is tell you *which* keys landed in the
|
|
11
|
+
uncertain zone, so your own reconciliation can go and check them. That is the
|
|
12
|
+
fourth assumption from the design guide (docs/the-four-assumptions.md): the boundary
|
|
13
|
+
between your record and a system you do not control.
|
|
14
|
+
|
|
15
|
+
Wire it like any other event handler::
|
|
16
|
+
|
|
17
|
+
from idemkit import MethodConfig, idempotent
|
|
18
|
+
from idemkit.contrib.reconciliation import reconciliation_handler
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def needs_check(event):
|
|
22
|
+
# Correlate back to a business operation via your own pending record,
|
|
23
|
+
# or trust the key you passed downstream, then ask the provider.
|
|
24
|
+
reconcile_queue.put(event.effective_key)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@idempotent(
|
|
28
|
+
backend=backend,
|
|
29
|
+
config=MethodConfig(
|
|
30
|
+
key_fields=["order_id"],
|
|
31
|
+
event_handlers=(reconciliation_handler(needs_check),),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
async def charge(*, order_id): ...
|
|
35
|
+
|
|
36
|
+
The handler fires only for the decisions below. Everything else (a clean run, a
|
|
37
|
+
replay, a rejected payload) is left alone, so a quiet queue means nothing to do.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from idemkit.core.events import EventHandler, IdempotencyEvent
|
|
43
|
+
from idemkit.core.state import Decision
|
|
44
|
+
|
|
45
|
+
#: The decisions where a side effect may have fired without a recorded result, so
|
|
46
|
+
#: the record and the downstream system can disagree. Each is emitted at most once
|
|
47
|
+
#: per operation; see spec §4.15 and docs/the-four-assumptions.md.
|
|
48
|
+
NEEDS_RECONCILIATION: frozenset[Decision] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
# The side effect ran, but writing its result failed. A retry re-runs it.
|
|
51
|
+
Decision.COMPLETE_FAILED,
|
|
52
|
+
# Our lease was reclaimed mid-run; our completion is fenced, but the side
|
|
53
|
+
# effect may already have fired before we lost the key.
|
|
54
|
+
Decision.LEASE_RECLAIMED_LOSS,
|
|
55
|
+
# The lease lapsed and the handler was cancelled part-way.
|
|
56
|
+
Decision.LEASE_LOST,
|
|
57
|
+
# fail_open ran the operation without dedup during a storage outage.
|
|
58
|
+
Decision.RAN_UNPROTECTED,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Decision.CORRUPT_RECORD is left out on purpose. It fires when a stored record cannot be
|
|
63
|
+
# deserialized, which is a genuine duplicate-risk case, but a codec or schema change can
|
|
64
|
+
# emit it in bulk for records that are only unreadable, not wrong. Reconcile those on
|
|
65
|
+
# their own track so a migration does not flood this signal.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reconciliation_handler(callback: EventHandler) -> EventHandler:
|
|
69
|
+
"""Build an event handler that calls ``callback`` only for uncertain completions.
|
|
70
|
+
|
|
71
|
+
``callback`` receives the :class:`~idemkit.IdempotencyEvent` for each decision in
|
|
72
|
+
:data:`NEEDS_RECONCILIATION` and nothing else. Use it to enqueue the key for a
|
|
73
|
+
reconciliation sweep, page an operator, or bump a metric. The event carries the
|
|
74
|
+
hashed ``effective_key`` (safe to log), the ``decision``, and the ``surface``; it
|
|
75
|
+
does not carry your business id, so correlate through your own pending record or
|
|
76
|
+
the key you passed downstream.
|
|
77
|
+
|
|
78
|
+
Like every event handler, a raised exception here is caught and suppressed by the
|
|
79
|
+
emitter, so a slow or broken sink never touches the request path.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def handle(event: IdempotencyEvent) -> None:
|
|
83
|
+
if event.decision in NEEDS_RECONCILIATION:
|
|
84
|
+
callback(event)
|
|
85
|
+
|
|
86
|
+
return handle
|
idemkit/contrib/sqs.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Amazon SQS glue for idemkit's queue surface.
|
|
2
|
+
|
|
3
|
+
SQS is at-least-once: it redelivers a message whenever a consumer doesn't delete
|
|
4
|
+
it within the visibility timeout. :func:`sqs_consumer` presets an
|
|
5
|
+
:class:`~idemkit.IdempotentConsumer` for the standard boto3 message shape so the
|
|
6
|
+
side effect runs once per SQS ``MessageId`` even across redeliveries, concurrent
|
|
7
|
+
consumers, and crashes.
|
|
8
|
+
|
|
9
|
+
Two SQS-specific defaults it gets right for you:
|
|
10
|
+
|
|
11
|
+
* **Dedup id = ``MessageId``.** Stable across redeliveries of the same message.
|
|
12
|
+
* **Attempt count = ``ApproximateReceiveCount``.** SQS's own receive count, so
|
|
13
|
+
``max_attempts`` is enforced durably (no in-process counter). Request it when
|
|
14
|
+
you receive: ``AttributeNames=["ApproximateReceiveCount"]`` — :func:`run_forever`
|
|
15
|
+
does this for you.
|
|
16
|
+
|
|
17
|
+
Ack = *delete the message*. :func:`run_forever` deletes on ``ACK`` and leaves the
|
|
18
|
+
message for redelivery on ``RETRY``.
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
import boto3
|
|
23
|
+
from idemkit import RedisBackend
|
|
24
|
+
from idemkit.contrib.sqs import sqs_consumer, run_forever
|
|
25
|
+
|
|
26
|
+
sqs = boto3.client("sqs")
|
|
27
|
+
consumer = sqs_consumer(
|
|
28
|
+
backend=RedisBackend.from_url("redis://localhost:6379"),
|
|
29
|
+
visibility_timeout_seconds=30,
|
|
30
|
+
config=QueueConfig(
|
|
31
|
+
max_attempts=5,
|
|
32
|
+
on_exhausted=lambda msg, exc: sqs.send_message(QueueUrl=DLQ, MessageBody=msg["Body"]),
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@consumer.handle
|
|
38
|
+
def process(msg) -> None:
|
|
39
|
+
charge_customer(msg["Body"]) # runs once per MessageId
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
run_forever(consumer, sqs_client=sqs, queue_url=QUEUE_URL, visibility_timeout=30)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import dataclasses
|
|
48
|
+
from collections.abc import Callable
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from idemkit.adapters.queue import ConsumerAction, IdempotentConsumer
|
|
52
|
+
from idemkit.backends.base import IdempotencyBackend
|
|
53
|
+
from idemkit.core.policy import QueueConfig
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sqs_receive_count(message: dict[str, Any]) -> int | None:
|
|
57
|
+
"""Read SQS ``ApproximateReceiveCount`` from a boto3 message dict."""
|
|
58
|
+
attrs = message.get("Attributes") or {}
|
|
59
|
+
value = attrs.get("ApproximateReceiveCount")
|
|
60
|
+
return int(value) if value is not None else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sqs_consumer(
|
|
64
|
+
*,
|
|
65
|
+
backend: IdempotencyBackend,
|
|
66
|
+
visibility_timeout_seconds: float,
|
|
67
|
+
config: QueueConfig | None = None,
|
|
68
|
+
) -> IdempotentConsumer:
|
|
69
|
+
"""Build an :class:`~idemkit.IdempotentConsumer` wired for boto3 SQS messages.
|
|
70
|
+
|
|
71
|
+
SQS presets the dedup id (``MessageId``) and the attempt count
|
|
72
|
+
(``ApproximateReceiveCount``). Pass a :class:`~idemkit.QueueConfig` for
|
|
73
|
+
behaviour (``max_attempts``, ``on_exhausted``, ``scope`` per queue, ...).
|
|
74
|
+
"""
|
|
75
|
+
cfg = config or QueueConfig()
|
|
76
|
+
presets: dict[str, Any] = {
|
|
77
|
+
"dedup_id": lambda m: m["MessageId"],
|
|
78
|
+
"visibility_timeout_seconds": visibility_timeout_seconds,
|
|
79
|
+
}
|
|
80
|
+
if cfg.receive_count is None:
|
|
81
|
+
presets["receive_count"] = sqs_receive_count
|
|
82
|
+
cfg = dataclasses.replace(cfg, **presets)
|
|
83
|
+
return IdempotentConsumer(backend=backend, config=cfg)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_forever(
|
|
87
|
+
consumer: IdempotentConsumer,
|
|
88
|
+
*,
|
|
89
|
+
sqs_client: Any,
|
|
90
|
+
queue_url: str,
|
|
91
|
+
visibility_timeout: int,
|
|
92
|
+
wait_time_seconds: int = 20,
|
|
93
|
+
max_messages: int = 10,
|
|
94
|
+
stop: Callable[[], bool] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Poll SQS and dispatch each message, deleting it on ``ACK``.
|
|
97
|
+
|
|
98
|
+
A blocking loop for a synchronous worker (it uses ``dispatch_sync``). Long-polls
|
|
99
|
+
with ``wait_time_seconds`` and requests ``ApproximateReceiveCount`` so attempt
|
|
100
|
+
counting is durable. On ``ACK`` the message is deleted; on ``RETRY`` it is left
|
|
101
|
+
for redelivery (the visibility timeout lapses). Pass ``stop=lambda: flag`` to
|
|
102
|
+
break the loop for graceful shutdown.
|
|
103
|
+
"""
|
|
104
|
+
while stop is None or not stop():
|
|
105
|
+
resp = sqs_client.receive_message(
|
|
106
|
+
QueueUrl=queue_url,
|
|
107
|
+
MaxNumberOfMessages=max_messages,
|
|
108
|
+
WaitTimeSeconds=wait_time_seconds,
|
|
109
|
+
VisibilityTimeout=visibility_timeout,
|
|
110
|
+
AttributeNames=["ApproximateReceiveCount"],
|
|
111
|
+
)
|
|
112
|
+
for message in resp.get("Messages", []):
|
|
113
|
+
result = consumer.dispatch_sync(message)
|
|
114
|
+
if result.action is ConsumerAction.ACK:
|
|
115
|
+
sqs_client.delete_message(
|
|
116
|
+
QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"]
|
|
117
|
+
)
|
idemkit/core/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from idemkit.core.config import IdempotencyConfig
|
|
2
|
+
from idemkit.core.events import EventEmitter, IdempotencyEvent
|
|
3
|
+
from idemkit.core.exceptions import (
|
|
4
|
+
ConfigurationError,
|
|
5
|
+
IdempotencyError,
|
|
6
|
+
StorageError,
|
|
7
|
+
)
|
|
8
|
+
from idemkit.core.fingerprint import (
|
|
9
|
+
FINGERPRINT_VERSION,
|
|
10
|
+
compute_effective_key,
|
|
11
|
+
compute_fingerprint,
|
|
12
|
+
)
|
|
13
|
+
from idemkit.core.state import (
|
|
14
|
+
ClaimResult,
|
|
15
|
+
ClaimResultType,
|
|
16
|
+
Decision,
|
|
17
|
+
State,
|
|
18
|
+
StoredRecord,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"FINGERPRINT_VERSION",
|
|
23
|
+
"ClaimResult",
|
|
24
|
+
"ClaimResultType",
|
|
25
|
+
"ConfigurationError",
|
|
26
|
+
"Decision",
|
|
27
|
+
"EventEmitter",
|
|
28
|
+
"IdempotencyConfig",
|
|
29
|
+
"IdempotencyError",
|
|
30
|
+
"IdempotencyEvent",
|
|
31
|
+
"State",
|
|
32
|
+
"StorageError",
|
|
33
|
+
"StoredRecord",
|
|
34
|
+
"compute_effective_key",
|
|
35
|
+
"compute_fingerprint",
|
|
36
|
+
]
|