Skip to content

Commit fbf92f1

Browse files
authored
Fine tuned the RedisBackend to fully support distributed systems (#16)
1 parent 374375c commit fbf92f1

9 files changed

Lines changed: 1043 additions & 8 deletions

File tree

docs/adapters/custom.md

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,296 @@ Everything works from here — decorators, CLI, dashboard, audit log — with SQ
219219

220220
---
221221

222+
## Distributed support
223+
224+
The six abstract methods give you persistence. To unlock full distributed behaviour — live dashboard updates, cross-instance global maintenance sync, and webhook deduplication — implement three additional optional methods. Each one has a default that works correctly for single-instance deployments, so you can add them incrementally.
225+
226+
---
227+
228+
### The three distributed methods
229+
230+
```python
231+
from collections.abc import AsyncIterator
232+
from shield.core.backends.base import ShieldBackend
233+
234+
class MyDistributedBackend(ShieldBackend):
235+
236+
async def subscribe(self) -> AsyncIterator[RouteState]:
237+
"""Stream every per-route state change as it happens.
238+
239+
Used by the dashboard SSE endpoint to push live updates to browsers
240+
without polling. Yield a RouteState every time set_state() is called
241+
by any instance. If your store does not support pub/sub, leave this
242+
unimplemented — the dashboard falls back to polling list_states()
243+
every few seconds automatically.
244+
"""
245+
...
246+
247+
async def subscribe_global_config(self) -> AsyncIterator[None]:
248+
"""Yield None whenever any instance writes a new global maintenance config.
249+
250+
ShieldEngine keeps GlobalMaintenanceConfig in an in-process cache to
251+
avoid a storage round-trip on every request. When another instance
252+
enables or disables global maintenance, it writes to the shared store
253+
and your implementation of this method should yield a signal so the
254+
engine drops its local cache and re-fetches on the next request.
255+
256+
Yield None for each change signal — the content does not matter,
257+
only the arrival of the message.
258+
"""
259+
...
260+
261+
async def try_claim_webhook_dispatch(
262+
self, dedup_key: str, ttl_seconds: int = 60
263+
) -> bool:
264+
"""Claim exclusive right to fire webhooks for one event.
265+
266+
When a scheduled maintenance window activates, every instance
267+
independently calls set_maintenance() and would each fire all
268+
registered webhooks — producing N deliveries for one event.
269+
270+
Before firing, ShieldEngine calls this method with a deterministic
271+
key derived from event + path + serialised RouteState (identical
272+
across all instances for the same event). The first instance to
273+
win the claim fires; all others return False and skip.
274+
275+
Use an atomic conditional write — "set this key only if it does not
276+
already exist" — and return True if you wrote it, False if it was
277+
already present. Set the key to expire after ttl_seconds so that a
278+
crashed instance does not permanently suppress re-delivery.
279+
280+
Return True unconditionally if your store does not support atomic
281+
conditional writes — webhooks will be over-delivered rather than
282+
silently dropped.
283+
"""
284+
...
285+
```
286+
287+
All three raise `NotImplementedError` by default. The engine handles each gracefully:
288+
289+
| Method | What happens if not implemented |
290+
|---|---|
291+
| `subscribe()` | Dashboard SSE falls back to polling `list_states()` every few seconds |
292+
| `subscribe_global_config()` | Global maintenance cache is per-process; stale until the process writes its own update |
293+
| `try_claim_webhook_dispatch()` | Always returns `True` — every instance fires webhooks (over-delivery) |
294+
295+
---
296+
297+
### PostgreSQL example
298+
299+
PostgreSQL's `LISTEN` / `NOTIFY` is a built-in pub/sub mechanism that works across connections and processes — no extra broker needed.
300+
301+
```python
302+
"""PostgreSQL distributed backend using asyncpg + LISTEN/NOTIFY.
303+
304+
pip install asyncpg
305+
"""
306+
307+
from __future__ import annotations
308+
309+
import asyncio
310+
import json
311+
from collections.abc import AsyncIterator
312+
from datetime import UTC, datetime
313+
314+
import asyncpg
315+
316+
from shield.core.backends.base import ShieldBackend
317+
from shield.core.models import AuditEntry, GlobalMaintenanceConfig, RouteState, RouteStatus
318+
319+
320+
class PostgresBackend(ShieldBackend):
321+
322+
def __init__(self, dsn: str) -> None:
323+
self._dsn = dsn
324+
self._pool: asyncpg.Pool | None = None
325+
326+
# ------------------------------------------------------------------
327+
# Lifecycle
328+
# ------------------------------------------------------------------
329+
330+
async def startup(self) -> None:
331+
self._pool = await asyncpg.create_pool(self._dsn)
332+
async with self._pool.acquire() as conn:
333+
await conn.execute("""
334+
CREATE TABLE IF NOT EXISTS shield_states (
335+
path TEXT PRIMARY KEY,
336+
state_json TEXT NOT NULL
337+
);
338+
CREATE TABLE IF NOT EXISTS shield_audit (
339+
id TEXT PRIMARY KEY,
340+
ts TIMESTAMPTZ NOT NULL,
341+
path TEXT NOT NULL,
342+
entry_json TEXT NOT NULL
343+
);
344+
CREATE TABLE IF NOT EXISTS shield_webhook_dedup (
345+
dedup_key TEXT PRIMARY KEY,
346+
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now()
347+
);
348+
""")
349+
350+
async def shutdown(self) -> None:
351+
if self._pool:
352+
await self._pool.close()
353+
354+
# ------------------------------------------------------------------
355+
# Core interface
356+
# ------------------------------------------------------------------
357+
358+
async def get_state(self, path: str) -> RouteState:
359+
async with self._pool.acquire() as conn:
360+
row = await conn.fetchrow(
361+
"SELECT state_json FROM shield_states WHERE path = $1", path
362+
)
363+
if row is None:
364+
raise KeyError(path)
365+
return RouteState.model_validate_json(row["state_json"])
366+
367+
async def set_state(self, path: str, state: RouteState) -> None:
368+
payload = state.model_dump_json()
369+
async with self._pool.acquire() as conn:
370+
await conn.execute(
371+
"""
372+
INSERT INTO shield_states (path, state_json) VALUES ($1, $2)
373+
ON CONFLICT (path) DO UPDATE SET state_json = EXCLUDED.state_json
374+
""",
375+
path, payload,
376+
)
377+
# Notify all listening instances of the per-route state change.
378+
await conn.execute("SELECT pg_notify('shield_changes', $1)", payload)
379+
380+
async def delete_state(self, path: str) -> None:
381+
async with self._pool.acquire() as conn:
382+
await conn.execute(
383+
"DELETE FROM shield_states WHERE path = $1", path
384+
)
385+
386+
async def list_states(self) -> list[RouteState]:
387+
async with self._pool.acquire() as conn:
388+
rows = await conn.fetch("SELECT state_json FROM shield_states")
389+
return [RouteState.model_validate_json(r["state_json"]) for r in rows]
390+
391+
async def write_audit(self, entry: AuditEntry) -> None:
392+
async with self._pool.acquire() as conn:
393+
await conn.execute(
394+
"""
395+
INSERT INTO shield_audit (id, ts, path, entry_json)
396+
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING
397+
""",
398+
entry.id, entry.timestamp, entry.path, entry.model_dump_json(),
399+
)
400+
401+
async def get_audit_log(
402+
self, path: str | None = None, limit: int = 100
403+
) -> list[AuditEntry]:
404+
async with self._pool.acquire() as conn:
405+
if path:
406+
rows = await conn.fetch(
407+
"SELECT entry_json FROM shield_audit WHERE path = $1"
408+
" ORDER BY ts DESC LIMIT $2",
409+
path, limit,
410+
)
411+
else:
412+
rows = await conn.fetch(
413+
"SELECT entry_json FROM shield_audit ORDER BY ts DESC LIMIT $1",
414+
limit,
415+
)
416+
return [AuditEntry.model_validate_json(r["entry_json"]) for r in rows]
417+
418+
# ------------------------------------------------------------------
419+
# Distributed: per-route live updates (dashboard SSE)
420+
# ------------------------------------------------------------------
421+
422+
async def subscribe(self) -> AsyncIterator[RouteState]:
423+
"""Stream RouteState changes via PostgreSQL LISTEN/NOTIFY."""
424+
queue: asyncio.Queue[RouteState] = asyncio.Queue()
425+
426+
def _on_notify(conn, pid, channel, payload):
427+
try:
428+
state = RouteState.model_validate_json(payload)
429+
queue.put_nowait(state)
430+
except Exception:
431+
pass
432+
433+
async with self._pool.acquire() as conn:
434+
await conn.add_listener("shield_changes", _on_notify)
435+
try:
436+
while True:
437+
yield await queue.get()
438+
finally:
439+
await conn.remove_listener("shield_changes", _on_notify)
440+
441+
# ------------------------------------------------------------------
442+
# Distributed: global maintenance cache invalidation
443+
# ------------------------------------------------------------------
444+
445+
async def set_global_config(self, config: GlobalMaintenanceConfig) -> None:
446+
"""Persist config and notify all instances to drop their cache."""
447+
await super().set_global_config(config)
448+
async with self._pool.acquire() as conn:
449+
# Empty string payload — only the arrival of the notification
450+
# matters, not its content.
451+
await conn.execute(
452+
"SELECT pg_notify('shield_global_invalidate', '1')"
453+
)
454+
455+
async def subscribe_global_config(self) -> AsyncIterator[None]:
456+
"""Yield None on each global config change via LISTEN/NOTIFY."""
457+
queue: asyncio.Queue[None] = asyncio.Queue()
458+
459+
def _on_notify(conn, pid, channel, payload):
460+
queue.put_nowait(None)
461+
462+
async with self._pool.acquire() as conn:
463+
await conn.add_listener("shield_global_invalidate", _on_notify)
464+
try:
465+
while True:
466+
yield await queue.get()
467+
finally:
468+
await conn.remove_listener("shield_global_invalidate", _on_notify)
469+
470+
# ------------------------------------------------------------------
471+
# Distributed: webhook deduplication
472+
# ------------------------------------------------------------------
473+
474+
async def try_claim_webhook_dispatch(
475+
self, dedup_key: str, ttl_seconds: int = 60
476+
) -> bool:
477+
"""Claim webhook dispatch rights using an INSERT ... ON CONFLICT DO NOTHING.
478+
479+
PostgreSQL's INSERT with ON CONFLICT is atomic — only one instance
480+
succeeds. A background cleanup query removes expired rows so the
481+
table does not grow indefinitely.
482+
"""
483+
async with self._pool.acquire() as conn:
484+
# Purge rows older than ttl_seconds first (best-effort cleanup).
485+
await conn.execute(
486+
"DELETE FROM shield_webhook_dedup"
487+
" WHERE claimed_at < now() - ($1 || ' seconds')::interval",
488+
str(ttl_seconds),
489+
)
490+
result = await conn.execute(
491+
"INSERT INTO shield_webhook_dedup (dedup_key)"
492+
" VALUES ($1) ON CONFLICT DO NOTHING",
493+
dedup_key,
494+
)
495+
# asyncpg returns "INSERT 0 1" when a row was inserted,
496+
# "INSERT 0 0" when ON CONFLICT suppressed the insert.
497+
return result == "INSERT 0 1"
498+
```
499+
500+
---
501+
502+
### What your store needs to support each method
503+
504+
| Method | Minimum capability required |
505+
|---|---|
506+
| `subscribe()` | Pub/sub or change-data-capture (PostgreSQL `LISTEN/NOTIFY`, MySQL binlog, Kafka, NATS) |
507+
| `subscribe_global_config()` | Same pub/sub as above — just a separate channel/topic |
508+
| `try_claim_webhook_dispatch()` | Atomic conditional write — "insert only if absent" (SQL `INSERT … ON CONFLICT DO NOTHING`, DynamoDB `PutItem` with `attribute_not_exists`, etcd transactions, Zookeeper ephemeral nodes, Memcached `add`) |
509+
510+
---
511+
222512
## Building a framework adapter
223513

224514
If you want to support a framework other than FastAPI, the pattern is:

docs/changelog.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
2828
- `_ShieldCallable` extended with an optional `signature=` override and updated `__call__` to forward the `response` kwarg to `dep_raise` when present — fully backward compatible with all existing decorators
2929
- `examples/fastapi/dependency_injection.py` updated to include `@deprecated` as a `Depends()` example and a clear explanation of why `@force_active` cannot be used as a dependency
3030

31+
#### Webhook Deduplication
32+
- `ShieldBackend` ABC gains `try_claim_webhook_dispatch(dedup_key, ttl_seconds)` — returns `True` if this instance should fire webhooks, `False` if another instance already claimed the right for this event. Default implementation always returns `True` (single-instance backends never need dedup).
33+
- `RedisBackend` overrides `try_claim_webhook_dispatch()` using `SET NX EX` — the first instance to win the atomic write fires webhooks; all others skip. Fails open: a Redis error returns `True` so webhooks are over-delivered rather than silently dropped.
34+
- `ShieldEngine._fire_webhooks` refactored — now schedules a single `_dispatch_webhooks` task instead of one task per URL. The task computes a deterministic SHA-256 dedup key from `event + path + serialised RouteState`, claims dispatch rights via the backend, then fans out to individual webhook URLs only if the claim succeeds.
35+
- Dedup key is deterministic across instances: because the scheduler produces an identical `RouteState` on all instances for the same window activation, the key is the same fleet-wide and only one instance wins.
36+
- TTL on the dedup key defaults to 60 seconds — if the winning instance crashes mid-dispatch the key expires and re-delivery is possible on the next activation cycle.
37+
38+
#### Distributed Global Maintenance
39+
- `RedisBackend` now publishes a lightweight invalidation signal to `shield:global_invalidate` whenever `set_global_config()` is called — any other instance subscribed to this channel immediately drops its in-process `GlobalMaintenanceConfig` cache
40+
- `ShieldBackend` ABC gains `subscribe_global_config()` — an async generator that yields `None` on each remote global config change; default implementation raises `NotImplementedError` (no-op for `MemoryBackend` and `FileBackend`)
41+
- `ShieldEngine.start()` — starts a background `asyncio.Task` that listens for global config invalidation signals and calls `_invalidate_global_config_cache()` on each one; idempotent, safe to call multiple times
42+
- `ShieldEngine.stop()` — cancels and awaits the listener task; called automatically by `__aexit__`
43+
- `ShieldEngine.__aenter__` / `__aexit__` updated to call `start()` / `stop()` so CLI scripts using `async with ShieldEngine(...)` get distributed invalidation automatically
44+
- `ShieldRouter.register_shield_routes()` calls `engine.start()` at application startup so FastAPI apps also start the listener without requiring the context manager
45+
- For `MemoryBackend` / `FileBackend` the new code path is a transparent no-op — `NotImplementedError` is caught, the task exits immediately, and single-instance cache behaviour is unchanged
46+
3147
#### Documentation & Communication
3248
- Early Access notice added to README and docs homepage — communicates that the library is fully functional and actively developed, and invites community feedback via GitHub Issues
3349
- Webhooks and Custom Responses added to the Key Features table in the docs homepage
3450
- Key Features section added to `README.md`
51+
- New guide: **Distributed Deployments** (`docs/guides/distributed.md`) — covers backend capability matrix, the request lifecycle across instances, global maintenance cache invalidation architecture, scheduler behaviour and webhook deduplication in multi-instance setups, OpenAPI schema staleness, the fail-open guarantee, and a production checklist. Explains why `FileBackend` intentionally does not support cross-instance sync and when to use each backend.
3552

3653
### Changed
3754
- `@deprecated` docstring updated — no longer described as decorator-only; documents the `Depends()` usage pattern

0 commit comments

Comments
 (0)