Skip to content

Commit b2ab4de

Browse files
authored
docs: plain up prose in README, guides, CONTEXT and AGENTS (#108)
1 parent 43746e6 commit b2ab4de

10 files changed

Lines changed: 62 additions & 62 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
# faststream-redis-timers
22

33
FastStream broker integration for Redis-backed distributed timer scheduling.
4-
[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary — read it before naming
4+
[`CONTEXT.md`](CONTEXT.md) opens with what it does and owns the vocabulary. Read it before naming
55
a concept in code, a test name, or an issue title.
66

77
## Commands
88

9-
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth —
9+
`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth:
1010
`just --list`, or read it. The things it does not say:
1111

1212
- `tests/test_unit.py`, `tests/test_fake.py`, `tests/test_store_seam.py` and
1313
`tests/test_tuning_defaults.py` need no Redis; the rest (the integration suites) do.
1414
- Verifying a Python-version / interpreter-compat change: run the **full** suite on the target
1515
interpreter (the CI matrix, or `just test` in docker), not just the no-Redis subset. An
16-
integration-only failure — e.g. a broker-shutdown hang that only bites under a specific
17-
interpreter's async timing — won't surface in the no-Redis subset alone.
16+
integration-only failure, such as a broker-shutdown hang that only bites under a specific
17+
interpreter's async timing, won't surface in the no-Redis subset alone.
1818

1919
## Workflow
2020

‎CONTEXT.md‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,24 @@ at-least-once delivery across a fleet of workers sharing one Redis.
99
A term is listed only when there is a synonym to reject, or a meaning subtle enough that code and
1010
docs must agree on it. General programming vocabulary does not belong here, however heavily this
1111
package uses it. Class and method names that mean what they say (`TimerStore`, `cancel_timer`) are
12-
not listed — read the module.
12+
not listed; read the module.
1313

14-
FastStream owns the framing vocabulary this package plugs into — broker, subscriber, publisher,
14+
FastStream owns the framing vocabulary this package plugs into: broker, subscriber, publisher,
1515
router, message, handler, `ack`/`nack`/`reject`. Nothing here redefines one of those.
1616

1717
**Timer**:
1818
A scheduled future delivery: a message bound to a Topic that becomes deliverable at its Activation
19-
time, identified by a `timer_id`. The Timer is the *record*, not the message it carries — a Timer
19+
time, identified by a `timer_id`. The Timer is the *record*, not the message it carries: a Timer
2020
has a lifecycle (Scheduled → Due → Claimed → Committed) that its payload does not.
2121

2222
**Topic**:
2323
The named channel a Timer is Scheduled on and that a subscriber consumes from. Two Redis keys are
2424
derived per Topic; a Timer only ever exists on one.
25-
_Avoid_: queue — a Topic is not a work queue, and the difference matters: Timers on a Topic are
25+
_Avoid_: queue. A Topic is not a work queue: Timers on a Topic are
2626
ordered by Activation time, not arrival, and a Claimed Timer stays on the Topic rather than leaving
2727
it. `channel` survives only where FastStream owns the spelling: the `raw_message` field, the
2828
log-context key, and `channel_labels` with the AsyncAPI document it feeds, where a Topic is a
29-
channel — say *AsyncAPI channel* there. In our own prose it is Topic.
29+
channel, so say *AsyncAPI channel* there. In our own prose it is Topic.
3030

3131
**Activation time**:
3232
The single absolute UTC instant at which a Timer becomes Due. Exactly one of `activate_in` (a
@@ -36,17 +36,17 @@ advances its Activation time rather than moving the Timer anywhere, so "Activati
3636
differently before and after a Claim.
3737

3838
**Schedule**:
39-
To register a new Timer to become Due at its Activation time. The method is `publish()` — FastStream
40-
names every producer entry point that way and this package keeps the parity — but the domain verb is
41-
Schedule, and prose should use it. There is no separate publish concept.
39+
To register a new Timer to become Due at its Activation time. The method is `publish()` because FastStream
40+
names every producer entry point that way and this package keeps the parity. The domain verb is
41+
still Schedule, and prose should use it. There is no separate publish concept.
4242

4343
**Due**:
4444
Describes a Timer whose Activation time has passed, and which is therefore eligible to be Claimed.
4545
Due is derived, never stored: it is a comparison against the current time, which is why the same
4646
Timer is Due to a poll one moment and not the next.
4747

4848
**Pending**:
49-
Describes a Timer that has been Scheduled and neither Committed nor cancelled — *including* one
49+
Describes a Timer that has been Scheduled and neither Committed nor cancelled, *including* one
5050
currently Claimed, whose Activation time sits in the future under its Lease. `has_pending` is
5151
therefore true for a Timer whose handler is already running; `get_pending_timers(before=now)`
5252
excludes those.
@@ -59,7 +59,7 @@ recorded, and nothing to release. A contested Claim returns nothing and is simpl
5959
**Lease**:
6060
The time window a Claim buys, `lease_ttl` seconds wide. It expires on its own; a worker that dies
6161
mid-handler releases nothing, the Timer simply becomes Due again. A handler that outruns its Lease
62-
can be redelivered while still running — that is what makes delivery at-least-once rather than
62+
can be redelivered while still running, which is what makes delivery at-least-once rather than
6363
exactly-once.
6464

6565
**Commit**:

‎README.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Schedule messages to be delivered to subscribers at a future point in time, with
2424

2525
> **Already using Postgres?** Prefer [`faststream-outbox`](https://github.com/modern-python/faststream-outbox), which also supports
2626
> [timers](https://faststream-outbox.modern-python.org/usage/timers/) and lets you schedule a timer in the **same SQLAlchemy
27-
> transaction** as the domain write — no dual-write between your DB and Redis. Reach for `faststream-redis-timers` when your stack
27+
> transaction** as the domain write, which avoids a dual-write between your DB and Redis. Reach for `faststream-redis-timers` when your stack
2828
> doesn't include Postgres, or when you want timer scheduling decoupled from your relational store.
2929
3030
```python
@@ -72,10 +72,10 @@ Timers are stored in Redis as two keys **per topic**:
7272
- A **sorted set** (`timers_timeline:{topic}`) with the activation timestamp as score
7373
- A **hash** (`timers_payloads:{topic}`) with the serialized message body
7474

75-
So a timer on the `invoices` topic lives under `timers_timeline:invoices` and `timers_payloads:invoices` — handy when poking around in `redis-cli`. A `TimersRouter(prefix="my-service:")` extends the suffix to `my-service:invoices`.
75+
So a timer on the `invoices` topic lives under `timers_timeline:invoices` and `timers_payloads:invoices`; those are the keys to look up in `redis-cli`. A `TimersRouter(prefix="my-service:")` extends the suffix to `my-service:invoices`.
7676

7777
A polling loop checks for due timers and atomically claims each one via a Lua
78-
script that pushes its score forward by `lease_ttl` seconds — granting the
78+
script that pushes its score forward by `lease_ttl` seconds, which grants the
7979
worker a lease. The timer is removed from Redis only **after** the handler
8080
completes successfully. If the worker crashes mid-handler or the handler
8181
raises, the lease eventually expires and another worker re-claims the timer.
@@ -93,7 +93,7 @@ await broker.cancel_timer("invoices", timer_id)
9393

9494
## Tracing & headers
9595

96-
`publish()` accepts `correlation_id` and `headers` — both round-trip to the handler via the standard FastStream `StreamMessage`:
96+
`publish()` accepts `correlation_id` and `headers`. Both round-trip to the handler via the standard FastStream `StreamMessage`:
9797

9898
```python
9999
await broker.publish(
@@ -120,7 +120,7 @@ async def handle(
120120

121121
## Connection ownership
122122

123-
`TimersBroker` does **not** close the Redis client you pass in — the caller owns
123+
`TimersBroker` does **not** close the Redis client you pass in; the caller owns
124124
its lifecycle. The same client can be shared across multiple brokers, so closing
125125
it from one broker would surprise the others. Manage the client with `async with`
126126
or a `try/finally`:
@@ -136,16 +136,16 @@ async with Redis.from_url("redis://localhost:6379") as client:
136136

137137
Per-subscriber knobs (passed to `@broker.subscriber("topic", ...)`):
138138

139-
- `lease_ttl` (default `30` seconds) — how long a worker holds the lease while
139+
- `lease_ttl` (default `30` seconds): how long a worker holds the lease while
140140
processing. Handlers must complete within this window or another worker may
141141
re-deliver the timer (duplicate). Increase if your handlers are slow.
142-
- `polling_interval` (default `0.05` s) — base poll interval used when the topic
142+
- `polling_interval` (default `0.05` s): base poll interval used when the topic
143143
has due timers or just transitioned from idle. Doubles on each consecutive empty
144144
cycle, capped at `max_polling_interval`, with ±50% jitter applied before the cap.
145-
- `max_polling_interval` (default `5.0` s) — ceiling for the adaptive idle
145+
- `max_polling_interval` (default `5.0` s): ceiling for the adaptive idle
146146
backoff. Lower it for tighter delivery latency on idle topics; raise it to
147147
reduce Redis load on workloads with long idle stretches.
148-
- `max_concurrent` (default `5`) — maximum number of handlers running in
148+
- `max_concurrent` (default `5`): maximum number of handlers running in
149149
parallel per subscriber. Also caps the fetch batch size per poll cycle.
150150
Handlers must be safe under concurrency in addition to being idempotent.
151151

@@ -155,7 +155,7 @@ Per-subscriber knobs (passed to `@broker.subscriber("topic", ...)`):
155155
ack failed to land in Redis (network blip) will be retried; a handler that
156156
takes longer than `lease_ttl` may be re-delivered to another worker.
157157
- **Buggy handler retries forever.** If a handler always raises, the timer is
158-
retried every `lease_ttl` seconds indefinitely — there is no built-in
158+
retried every `lease_ttl` seconds indefinitely, since there is no built-in
159159
attempt counter. Raise `faststream.exceptions.RejectMessage` from your
160160
handler to drop a poison-pill timer permanently, or track attempts in your
161161
own state if you need a hard cap.
@@ -188,4 +188,4 @@ broker = TimersBroker(
188188
## Part of `modern-python`
189189

190190
Browse the full list of templates and libraries in
191-
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
191+
[`modern-python`](https://github.com/modern-python); the org profile has the categorized index.

‎docs/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Schedule messages to be delivered to subscribers at a future point in time, with
1414
!!! tip "Already using Postgres? Prefer `faststream-outbox`"
1515
[`faststream-outbox`](https://github.com/modern-python/faststream-outbox) also supports
1616
[timers](https://faststream-outbox.modern-python.org/usage/timers/) and lets you schedule a timer in the
17-
**same SQLAlchemy transaction** as the domain write — no dual-write between your DB and Redis. Reach for
17+
**same SQLAlchemy transaction** as the domain write, which avoids a dual-write between your DB and Redis. Reach for
1818
`faststream-redis-timers` when your stack doesn't include Postgres, or when you want timer scheduling decoupled
1919
from your relational store.
2020

‎docs/introduction/how-it-works.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,26 @@ ZADD timers_timeline:invoices <activation_ts> abc-123
1616
HSET timers_payloads:invoices abc-123 <encoded_envelope>
1717
```
1818

19-
The envelope is FastStream's `BinaryMessageFormatV1` — a binary-safe wire format that carries the message body alongside `correlation_id`, `content-type`, `reply_to`, and any user-supplied headers. The same format is used by FastStream's built-in Redis broker.
19+
The envelope is FastStream's `BinaryMessageFormatV1`, a binary-safe wire format that carries the message body alongside `correlation_id`, `content-type`, `reply_to`, and any user-supplied headers. The same format is used by FastStream's built-in Redis broker.
2020

2121
## Polling loop
2222

2323
Each subscriber runs a background polling loop that:
2424

2525
1. Calls `ZRANGEBYSCORE timers_timeline:{topic} -inf <now> LIMIT 0 <free>` (where `<free>` is the unused capacity from the per-subscriber concurrency limiter) to find due timers
26-
2. For each due timer, spawns a task that — bounded by the `max_concurrent` capacity limiter — runs an atomic Lua **claim** script that:
26+
2. For each due timer, spawns a task (bounded by the `max_concurrent` capacity limiter) that runs an atomic Lua **claim** script that:
2727
- Verifies the timer is still due (score ≤ now)
28-
- Pushes its score forward by `lease_ttl` seconds — granting the worker a lease
28+
- Pushes its score forward by `lease_ttl` seconds, which grants the worker a lease
2929
- Returns the payload
3030
3. Delivers the payload to the user handler
3131
4. On handler success, runs an atomic Lua **commit** script (`ZREM` + `HDEL`) to remove the timer from Redis
32-
5. On handler exception, leaves the timer in place — the lease eventually expires and another worker re-claims it
32+
5. On handler exception, leaves the timer in place; the lease eventually expires and another worker re-claims it
3333

34-
This is the standard SQS-style **visibility-timeout** pattern. The timer's own score in the sorted set acts as the lease deadline — there is no separate lock primitive.
34+
This is the standard SQS-style **visibility-timeout** pattern. The timer's own score in the sorted set is the lease deadline; there is no separate lock primitive.
3535

3636
## At-least-once delivery
3737

38-
The lease ensures each due timer is processed by exactly one worker at a time. Because the timer is removed from Redis only after the handler completes successfully, **no timer is lost on crash** — if the worker dies mid-handler, the lease expires and the timer is re-delivered.
38+
The lease ensures each due timer is processed by exactly one worker at a time. Because the timer is removed from Redis only after the handler completes successfully, **no timer is lost on crash**: if the worker dies mid-handler, the lease expires and the timer is re-delivered.
3939

4040
The trade-off: handlers that take longer than `lease_ttl`, or workers that crash after the handler ran but before the commit landed, may see the timer delivered more than once. Handlers must therefore be **idempotent**.
4141

@@ -44,8 +44,8 @@ The trade-off: handlers that take longer than `lease_ttl`, or workers that crash
4444
| Action | Effect |
4545
|--------|--------|
4646
| `ack` | Atomically removes the timer from `timers_timeline` and `timers_payloads` |
47-
| `nack` | No-op — the lease expires and another worker re-claims the timer |
48-
| `reject` | Same as `ack` — permanently removes the timer (use for poison-pill messages) |
47+
| `nack` | No-op; the lease expires and another worker re-claims the timer |
48+
| `reject` | Same as `ack`: permanently removes the timer (use for poison-pill messages) |
4949

5050
The default ack policy is `NACK_ON_ERROR`: the timer is acknowledged on success, and left for retry on any unhandled exception.
5151

@@ -64,7 +64,7 @@ The default ack policy is `NACK_ON_ERROR`: the timer is acknowledged on success,
6464
| Parameter | Default | Description |
6565
|-----------|---------|-------------|
6666
| `polling_interval` | `0.05` s | Base poll interval used when the topic has due timers or just transitioned from idle |
67-
| `max_polling_interval` | `5.0` s | Ceiling for the adaptive idle backoff — `polling_interval` doubles up to this value on consecutive empty polls. Jitter is applied before the cap, so worst-case delivery latency on a previously-idle topic is `max_polling_interval` |
67+
| `max_polling_interval` | `5.0` s | Ceiling for the adaptive idle backoff: `polling_interval` doubles up to this value on consecutive empty polls. Jitter is applied before the cap, so worst-case delivery latency on a previously-idle topic is `max_polling_interval` |
6868
| `max_concurrent` | `5` | Max handlers running concurrently per subscriber; also bounds fetch batch size |
6969
| `lease_ttl` | `30` s | How long a worker holds the lease before another worker may re-claim. Set to ~3–5× the P99 handler runtime: lower values speed up recovery from worker death, higher values tolerate handler GC pauses and clock skew |
7070

0 commit comments

Comments
 (0)