Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f61dffbb5a | |||
| f7f007dce8 | |||
| 20992883ff | |||
| a622419e7d |
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "plan",
|
||||
"phase": 1,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.4",
|
||||
"phase_role": "pre_execution",
|
||||
"phase_role": "execution",
|
||||
"project": "atelier",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-05T05:20:00Z",
|
||||
"updated_at": "2026-08-05T06:15:00Z",
|
||||
"milestone_complete": false,
|
||||
"milestone_branch": "milestone/v0.4-edge-quantum-langs",
|
||||
"phase_branch": "phase/00-pre-execution",
|
||||
"tag_base": "v0.3"
|
||||
"phase_branch": "phase/01-edge",
|
||||
"tag_base": "v0.3",
|
||||
"phase_tag": "v0.3.1",
|
||||
"release_id": 479
|
||||
}
|
||||
@@ -130,8 +130,8 @@ NFR milestone: no separate minor tag. The final patch (v0.2.6) IS the v0.3 deliv
|
||||
|
||||
| Phase | Name | Type | Status | Key Deliverables |
|
||||
|-------|------|------|--------|------------------|
|
||||
| 0 | Pre-Execution | docs | active | Spec, clarify, research, ideate, plan, PERSONAS.md (adds edge-engineer + languages-engineer phase-specific personas) |
|
||||
| 1 | Edge Domain | docs | pending | domains/edge/{first-principles, cdn, offline-first, iot, sync}.md |
|
||||
| 0 | Pre-Execution | docs | complete | Spec, clarify, research, ideate, plan, PERSONAS.md (adds edge-engineer + languages-engineer phase-specific personas) — shipped v0.3.0 |
|
||||
| 1 | Edge Domain | docs | complete | domains/edge/{first-principles, cdn, offline-first, iot, sync}.md — shipped v0.3.1 |
|
||||
| 2 | Messaging Domain | docs | pending | domains/messaging/{first-principles, queues, pubsub, streams, delivery-semantics}.md |
|
||||
| 3 | Language-Derived Extension | docs | pending | languages/ × 4 → first-principles + 4 derived docs each (16 derived docs) |
|
||||
| 4 | Matrix + Review Integration | docs | pending | matrix/principles-matrix.md (+20 mappings, 170→190), matrix/domain-coverage.md (+ languages/ sub-table), review/{agent-checklist, peer-review-checklist, anti-patterns}.md, MANIFEST.md (languages/ section) |
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# CDN — Derived Rules
|
||||
|
||||
> Derives from `domains/edge/first-principles.md`. Applies P1
|
||||
> (Proximity is the Design Driver) and P6 (Cache Invalidation is
|
||||
> Explicit) primarily, with P5 (idempotent cache fill), P8
|
||||
> (geographic distribution), and P9 (identity at the edge). For the
|
||||
> edge-cache-vs-origin decision, see the decision matrix below.
|
||||
> Cross-links `domains/performance/frontend` for generic caching,
|
||||
> `domains/security/input-validation` for cache poisoning, and
|
||||
> `domains/observability/metrics` for cache-hit ratio.
|
||||
|
||||
## What a CDN Is (P1 Proximity is the Design Driver)
|
||||
|
||||
- A content delivery network is a fleet of PoPs (points of presence)
|
||||
placed near users. The PoP serves cached content; the origin is
|
||||
the authoritative source. The CDN's whole purpose is P1: compute
|
||||
(the cache) is placed near the user so the round trip to the origin
|
||||
does not bound latency. Latency is a correctness constraint at the
|
||||
edge (C1), not a performance preference.
|
||||
- The CDN is the canonical edge-cache architecture (Akamai,
|
||||
Cloudflare, Fastly): PoPs near users, origin shielding, cache-key
|
||||
normalization, purge APIs. Atelier derives the
|
||||
placement/invalidation principles, not the vendor config.
|
||||
- The boundary with `domains/performance/frontend` is per D-061:
|
||||
performance owns *generic* caching and optimization (cache what is
|
||||
expensive, stable, read often — `performance/P5 Caching with
|
||||
Intent`); edge owns the *geographic, partition-aware* placement and
|
||||
invalidation angle. A CDN is an edge concern because its defining
|
||||
trait is geographic distribution (P8) and partition-aware
|
||||
invalidation (P6), not measurement.
|
||||
|
||||
## Cache Key Design (P6 Cache Invalidation is Explicit)
|
||||
|
||||
- The cache key is the contract between the URL and the cached
|
||||
representation. A key that varies on the wrong dimensions serves
|
||||
the wrong content; a key that varies on too many dimensions
|
||||
collapses the hit ratio. Key design *is* the invalidation
|
||||
surface: a key that includes a content hash or version segment
|
||||
makes invalidation explicit; a key that ignores `Vary` headers
|
||||
serves stale variants.
|
||||
- Normalize the key: lower-case the host, strip default ports,
|
||||
sort query parameters, ignore tracking parameters. A
|
||||
non-normalized key is a cache-poisoning vector (see
|
||||
`domains/security/input-validation`) and a hit-ratio destroyer
|
||||
(see `domains/observability/metrics`).
|
||||
- A cache with no explicit key strategy is a TTL-less cache under
|
||||
partition (P6 violation): staleness is silent and unbounded.
|
||||
|
||||
```http
|
||||
# Cache key derivation: vary on what changes content, ignore what
|
||||
# does not. The key is the tuple (host, normalized-path, sorted-
|
||||
# query, Vary-headers); the cache entry is the representation + TTL.
|
||||
Cache-Key: example.com /api/v1/products?sort=price®ion=us Vary:Accept-Encoding
|
||||
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=300
|
||||
Vary: Accept-Encoding
|
||||
```
|
||||
|
||||
- `max-age` bounds the browser cache; `s-maxage` bounds the CDN
|
||||
PoP; `stale-while-revalidate` allows serving stale while
|
||||
refetching. Each is an explicit invalidation strategy (P6).
|
||||
|
||||
## TTL vs Explicit Invalidation (P6, C3 Simplicity)
|
||||
|
||||
- **TTL-based invalidation** (`max-age`, `s-maxage`): the cache entry
|
||||
expires after a duration. Simple, no origin contact required to
|
||||
invalidate, but bounded staleness is the contract — the entry may
|
||||
be stale up to TTL. Fits content where eventual consistency is
|
||||
acceptable (asset fingerprints, lists, derived images).
|
||||
- **Explicit invalidation** (purge, surrogate keys): the operator
|
||||
signals the cache to drop entries. Tighter staleness bounds, but
|
||||
requires the origin or operator to know which entries to purge.
|
||||
Fits content where staleness is a correctness defect (price
|
||||
updates, availability, breaking news).
|
||||
- A TTL-less cache with no explicit invalidation is a P6 violation:
|
||||
stale-forever under partition. Every cache must have one or the
|
||||
other (or both), and the choice is documented per content type.
|
||||
|
||||
## Cache-Hit / Miss / Origin-Fetch (P5, P6)
|
||||
|
||||
- **Hit**: the PoP serves from cache. Latency is PoP-local (P1).
|
||||
- **Miss**: the PoP has no entry; it fetches from the origin (or an
|
||||
origin-shield PoP). The fetch must be idempotent (P5) — a retried
|
||||
miss must not corrupt the cache or double-write side effects.
|
||||
- **Revalidate**: the PoP holds a stale entry and asks the origin
|
||||
(`If-None-Match`, `If-Modified-Since`); a 304 refreshes the TTL
|
||||
without re-fetching the body. Revalidation is the bandwidth-economical
|
||||
middle ground (C8).
|
||||
|
||||
```http
|
||||
# Conditional revalidation — the PoP asks the origin "is this still
|
||||
# current?" The 304 response refreshes the TTL without a body.
|
||||
GET /api/v1/products HTTP/1.1
|
||||
Host: example.com
|
||||
If-None-Match: "etag-7a3f"
|
||||
|
||||
HTTP/1.1 304 Not Modified
|
||||
ETag: "etag-7a3f"
|
||||
Cache-Control: s-maxage=600
|
||||
```
|
||||
|
||||
- A cache-hit ratio that is not measured is a gate on noise — see
|
||||
`domains/observability/metrics` for the SLI/SLO discipline that
|
||||
makes the hit ratio a meaningful signal. A CDN with no hit-ratio
|
||||
metric is operating blind (P10 analog).
|
||||
|
||||
## Origin Shielding (P1, P8, C8 Economy)
|
||||
|
||||
- Origin shielding routes all origin fetches through a single
|
||||
shield PoP (or shield region). The shield absorbs the
|
||||
thundering-herd: 10 000 PoPs missing the same URL fetch the origin
|
||||
once, not 10 000 times. This is C8 Economy (origin bandwidth is
|
||||
bounded) and P1 (the shield is itself a proximity layer for the
|
||||
origin).
|
||||
- Shielding is a geographic decision (P8): the shield sits in a
|
||||
region close to the origin, not close to the user. The shield is
|
||||
the inner ring of the CDN; the user-facing PoPs are the outer ring.
|
||||
- A CDN without origin shielding under a stampede will overload the
|
||||
origin; a shield that is itself partitioned from the origin must
|
||||
degrade gracefully (P7) — serve stale per `stale-while-revalidate`
|
||||
rather than 500.
|
||||
|
||||
## Purge Strategies (P6, C3 Simplicity)
|
||||
|
||||
| Strategy | Granularity | Latency to Invalidate | Cost | Best for |
|
||||
|----------|-------------|-----------------------|------|----------|
|
||||
| URL purge | One URL | Seconds | Low (one entry) | Surgical fixes, single-page corrections |
|
||||
| Soft purge | One URL (mark stale, serve while refetch) | Seconds | Low | High-traffic URLs where a hard purge causes a stampede |
|
||||
| Surrogate-key purge | A tag set (e.g., `product:123`, `category:shoes`) | Seconds | Medium (key indexing) | Related-content invalidation (a product update purges all its category pages) |
|
||||
| Wildcard purge | A path prefix or pattern | Seconds to minutes | High (scan) | Site-wide template changes |
|
||||
| All-cache purge | Everything | Seconds | Very high (origin stampede) | Disaster recovery only; never the steady-state invalidation path |
|
||||
|
||||
- Surrogate-key purge (Fastly, Akamai) is the highest-value
|
||||
strategy: tag cache entries with content keys, then purge by tag.
|
||||
This is explicit invalidation at scale (P6) without the origin
|
||||
stampede of an all-cache purge.
|
||||
- An all-cache purge as the steady-state invalidation path is a P6
|
||||
violation dressed as a feature — it pushes the origin load back to
|
||||
100% miss, defeating the CDN's purpose (P1).
|
||||
|
||||
## Cache Poisoning Prevention (P9, cross-link security/input-validation)
|
||||
|
||||
- A cache poisoned by a crafted request (a URL with a malicious
|
||||
header that gets cached and served to others) is a correctness
|
||||
defect (C1) and a security breach (P9 — the edge node is
|
||||
exploited). Prevent poisoning by:
|
||||
- Normalizing the cache key (strip untrusted query parameters,
|
||||
ignore unknown headers, lower-case the host).
|
||||
- Validating `Vary` against an allow-list; never `Vary: *` on a
|
||||
shared cache (poisonable via header injection).
|
||||
- Treating uncacheable responses (`Set-Cookie`,
|
||||
`Cache-Control: private`) as never-stored.
|
||||
- See `domains/security/input-validation` for the general
|
||||
input-validation discipline the cache key must follow. The cache
|
||||
key is a validation surface; a non-validated key is an attack
|
||||
surface.
|
||||
|
||||
## Multi-CDN Routing (P8 Geographic Distribution)
|
||||
|
||||
- A multi-CDN strategy routes each request to the best PoP across
|
||||
providers (Akamai + Cloudflare + Fastly). Routing is
|
||||
location-aware (P8): latency, cost, and availability vary by
|
||||
region and provider. The DNS layer (or a client-side router)
|
||||
selects the CDN per request.
|
||||
- Multi-CDN is a P8 decision, not a vendor-management decision:
|
||||
geographic distribution is the first-class constraint. A
|
||||
single-CDN deployment routes everything to one provider's PoPs;
|
||||
a multi-CDN deployment routes by region, latency, and cost.
|
||||
- Invalidation across multiple CDNs is harder (P6): each provider
|
||||
has its own purge API and surrogate-key scheme. A multi-CDN purge
|
||||
must fan out to all providers; a purge that reaches only one CDN
|
||||
leaves the others stale. Track purge completion per provider —
|
||||
see `domains/observability/metrics` for the per-CDN hit-ratio and
|
||||
purge-latency signals.
|
||||
|
||||
```http
|
||||
# A CDN config example: cache-control headers + a purge rule.
|
||||
# Origin response: declare the cache contract (P6).
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=300
|
||||
Surrogate-Key: product:123 category:shoes
|
||||
ETag: "etag-7a3f"
|
||||
Vary: Accept-Encoding
|
||||
|
||||
# Purge rule (Fastly-style surrogate-key): when product 123
|
||||
# updates, purge every cache entry tagged product:123 OR
|
||||
# category:shoes. Explicit, bounded, no origin stampede (P6).
|
||||
POST /service/svc1/purge
|
||||
Surrogate-Key: product:123 category:shoes
|
||||
# Returns: {"status": "ok", "id": "purge-abc"} — poll the purge
|
||||
# status to confirm completion across all PoPs (P8, P10).
|
||||
```
|
||||
|
||||
## Edge-Cache vs Origin — Decision Matrix (D-069)
|
||||
|
||||
| Strategy | When | Latency | Origin Load | Correctness Risk |
|
||||
|----------|------|---------|-------------|------------------|
|
||||
| Serve from PoP (cache hit) | The PoP holds a fresh entry (within TTL or revalidated) | Lowest (PoP-local, P1) | None | Low — bounded by TTL staleness (P6) |
|
||||
| Serve stale while revalidate | The PoP holds a stale entry and `stale-while-revalidate` is set | Low (stale served immediately, refetch in background) | Background refetch (1 per entry) | Medium — stale served up to the revalidate window; acceptable for eventually-consistent content |
|
||||
| Fetch fresh from origin (miss) | The PoP has no entry, or the content is non-cacheable | High (origin round trip) | Full fetch per miss | Low — fresh by construction; the miss is the correctness floor |
|
||||
| Origin-shield fetch | Multiple PoPs miss the same URL; the shield collapses the herd | Medium (PoP → shield → origin) | Bounded to one origin fetch per shield (C8) | Low — shield is the inner ring; staleness bounded by shield TTL |
|
||||
| Purge and serve fresh | Explicit invalidation received (surrogate-key or URL purge) | Medium (purge propagates, then fresh fetch) | Full fetch post-purge | Lowest — explicit invalidation is the tightest staleness bound (P6) |
|
||||
| Serve from origin directly (bypass cache) | Content is non-cacheable (personalized, real-time) | Highest (every request hits origin) | Full fetch per request | Lowest for correctness, highest for origin load — use sparingly |
|
||||
|
||||
- The default is **serve from PoP** when fresh, **fetch fresh from
|
||||
origin** on miss with **origin-shield** to bound origin load, and
|
||||
**purge and serve fresh** when explicit invalidation is required.
|
||||
Bypass-the-cache is for non-cacheable content only — bypassing for
|
||||
cacheable content is a P1 violation (you have defeated the CDN).
|
||||
- The correctness risk column is bounded by the invalidation
|
||||
strategy (P6): every row except "bypass" carries staleness risk
|
||||
that is bounded by TTL or explicit purge. A row with no
|
||||
invalidation strategy is a P6 violation.
|
||||
|
||||
## What Violates CDN Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| TTL-less edge cache under partition (stale-forever, no explicit invalidation) | P6 Cache Invalidation is Explicit |
|
||||
| Cache key that varies on untrusted query parameters (poisonable) | P6, P9 (`domains/security/input-validation`) |
|
||||
| All-cache purge as the steady-state invalidation path (origin stampede) | P6, C8 Economy |
|
||||
| Non-normalized cache key (case-sensitive host, unsorted query) | P6, `domains/security/input-validation` |
|
||||
| Bypass-the-cache for cacheable content | P1 Proximity is the Design Driver (defeats the CDN) |
|
||||
| Multi-CDN with no per-CDN purge completion tracking | P8, P10 (stale cache invisible to the operator) |
|
||||
| Origin fetch that is not idempotent under retry | P5 Edge Operations are Idempotent |
|
||||
| Cache-hit ratio not measured | P10, `domains/observability/metrics` |
|
||||
| Single-CDN deployed where geographic distribution requires multi-CDN | P8 Geographic Distribution |
|
||||
| Shield PoP that 500s instead of serving stale under partition | P7 Partial Degradation is Engineered |
|
||||
@@ -0,0 +1,218 @@
|
||||
# Edge — First Principles
|
||||
|
||||
## 1. The Principles
|
||||
|
||||
### P1. Proximity is the Design Driver
|
||||
Compute, storage, and data are placed near the user or the data
|
||||
source. At the edge, latency is a correctness constraint (C1), not a
|
||||
performance preference — a late answer is a wrong answer when the
|
||||
round trip to a central region exceeds the user's or device's
|
||||
tolerance. This is the geographic expression of `C4 Locality`:
|
||||
performance's locality is algorithmic (data near compute); edge's
|
||||
locality is geographic (compute near user/data source). Placement is
|
||||
a design decision, not an accident of deployment, and it is
|
||||
constrained by `P8 Geographic Distribution`. The proximity angle is
|
||||
the distinguishing trait of the edge domain per D-061: this is what
|
||||
separates edge from `domains/performance/` (which owns *generic*
|
||||
measurement and optimization, not placement).
|
||||
|
||||
### P2. Offline is a First-Class State
|
||||
The system continues to operate when disconnected from the center.
|
||||
Partition is the norm, not the exception; reconciliation happens on
|
||||
reconnect, never assumed to be instant. An app that crashes on
|
||||
disconnect has no offline state and is unengineered. Offline
|
||||
operation derives from `C5 Reversibility` — the disconnected state
|
||||
is reversible back to consistency via reconciliation — and `C1
|
||||
Correctness`, because correctness under partition is the contract,
|
||||
not eventual correctness as a hedge. This is the foundation for
|
||||
`domains/edge/offline-first.md` and the precondition for the
|
||||
bounded-conflict discipline of `P4`.
|
||||
|
||||
### P3. Resources are Constrained and Declared
|
||||
Edge nodes — IoT sensors, gateways, point-of-sale devices, CDN PoP
|
||||
caches, 5G MEC nodes — have bounded CPU, memory, power, and
|
||||
bandwidth. Constraints are declared per node class, never assumed
|
||||
infinite. An undeclared budget is a defect: unbounded growth is a
|
||||
bug, and a constrained device with no budget will OOM or exhaust
|
||||
power. This derives from `C8 Economy` (use no more than the task
|
||||
requires) and `C1 Correctness` (a node that exceeds its bounds has
|
||||
failed). This is the edge-specific angle on `domains/performance/P4
|
||||
Resource Bounds` — performance owns the generic principle; edge owns
|
||||
the constrained-device reality. See `domains/edge/iot.md` for the
|
||||
per-device-class application.
|
||||
|
||||
### P4. Sync Conflicts are Bounded, Not Infinite
|
||||
Divergent state across partitioned nodes converges. Oscillation and
|
||||
infinite sync loops are correctness failures, not eventual
|
||||
consistency. A merge that never terminates is a livelock; a CRDT
|
||||
without merge semantics or an LWW without a monotonic clock can
|
||||
oscillate forever. This derives from `C1 Correctness` (convergence is
|
||||
a correctness contract) and `C5 Reversibility` (divergent state is
|
||||
reversible back to convergence). The bound may be eventual (CRDTs) or
|
||||
arbitrated (LWW with vector clocks), but it must exist. This is the
|
||||
foundation for `domains/edge/sync.md` and the rule the
|
||||
`edge-sync-loop` chaos anti-pattern breaches.
|
||||
|
||||
### P5. Edge Operations are Idempotent
|
||||
Sync, cache fill, and device commands are retried by nature — the
|
||||
network is partition-prone and the operation will be re-attempted.
|
||||
Idempotency keys (or deterministic operations) make retries safe. A
|
||||
non-idempotent edge write retried with side effects doubles the
|
||||
effect; a non-idempotent cache fill under retry corrupts the cache.
|
||||
This derives from `C1 Correctness`: correctness under retry is the
|
||||
contract, not a nice-to-have. This parallels `domains/messaging/P3
|
||||
Consumers are Idempotent` (cross-process delivery) and is the edge's
|
||||
device-and-cache-flavored analog — see `domains/edge/iot.md` for
|
||||
device command idempotency and `domains/edge/cdn.md` for cache-fill
|
||||
idempotency.
|
||||
|
||||
### P6. Cache Invalidation is Explicit
|
||||
Edge caches carry a defined invalidation or TTL strategy. A
|
||||
stale-forever cache under partition is a silent correctness defect;
|
||||
a TTL-less cache with no explicit invalidation is a bug, not a
|
||||
feature. This derives from `C1 Correctness` (cached state must be
|
||||
correct) and `C3 Simplicity` (a defined invalidation strategy is
|
||||
simpler and clearer than ad-hoc staleness). This is distinct from
|
||||
`domains/performance/P5 Caching with Intent`, which owns *generic*
|
||||
caching and optimization; edge owns the *geographic,
|
||||
partition-aware* invalidation angle — when a PoP is partitioned from
|
||||
the origin, the invalidation strategy is the correctness mechanism.
|
||||
See `domains/edge/cdn.md` for purge strategies (URL vs soft vs
|
||||
surrogate-key) and the edge-cache-vs-origin decision matrix.
|
||||
|
||||
### P7. Partial Degradation is Engineered
|
||||
The system degrades gracefully when an edge node or link fails. A
|
||||
partial service is a designed state with a defined contract, not a
|
||||
crash. One node's failure must not collapse the whole fleet; the
|
||||
degraded mode is documented, observable, and recoverable. This
|
||||
derives from `C1 Correctness` (the degraded contract is a
|
||||
correctness bound) and `C5 Reversibility` (recovery from degradation
|
||||
is reversible by construction). A crash-on-node-failure system has
|
||||
no degradation contract — it has an all-or-nothing failure mode that
|
||||
violates the fleet assumption. See `domains/edge/iot.md` for
|
||||
device-drop degradation and `domains/edge/offline-first.md` for
|
||||
partition degradation.
|
||||
|
||||
### P8. Geographic Distribution is a First-Class Constraint
|
||||
The fleet is geo-distributed; routing, fan-out, and data placement
|
||||
are location-aware decisions, not accidents of deployment. The
|
||||
system is many nodes across many locations, not a single deployment.
|
||||
Data residency, regional latency, and PoP selection are engineered,
|
||||
not discovered in production. This derives from `C4 Locality` (the
|
||||
placement of data and compute is a locality decision) and `C6
|
||||
Composability` (the fleet composes from location-aware parts, each
|
||||
with its own contract). This is the structural companion to `P1
|
||||
Proximity`: P1 says *where* compute should be (near the user); P8
|
||||
says the *distribution* of compute across geographies is a
|
||||
first-class constraint. See `domains/edge/cdn.md` for multi-CDN
|
||||
routing.
|
||||
|
||||
### P9. Identity is Constrained at the Edge
|
||||
Edge devices and nodes hold scoped, minimal credentials. No edge
|
||||
node is a cluster-admin-equivalent; device identity is per-device,
|
||||
not shared. One compromise must not equal a fleet compromise. This
|
||||
derives from `C1 Correctness` (security is a subset of correctness —
|
||||
an exploitable edge node does not do what it was supposed to do) and
|
||||
`C8 Economy` of trust (the credential scope is minimal for the task).
|
||||
A shared edge-device credential is the edge analog of a
|
||||
cluster-admin GitOps robot — blast radius is unbounded. See
|
||||
`domains/security/secrets.md` for the general secret-hygiene
|
||||
principles and `domains/edge/iot.md` for device provisioning.
|
||||
|
||||
### P10. Edge Observability Survives Partition
|
||||
Telemetry is local-first: buffered on the node and forwarded on
|
||||
reconnect. Partition does not blind the operator. A fire-and-forget
|
||||
telemetry pipeline loses data when the link drops; a local-first
|
||||
buffer survives. This derives from `C7 Observability` (the fleet's
|
||||
behavior is visible to the operator) and `C5 Reversibility` (the
|
||||
buffered telemetry is reversible back to visibility on reconnect).
|
||||
This is distinct from `domains/observability/P1 Structured by
|
||||
Default`, which owns *generic* structured telemetry; edge owns the
|
||||
*partition-survivable, local-first* angle. See
|
||||
`domains/observability/metrics.md` and
|
||||
`domains/observability/logging.md` for the generic structured-
|
||||
telemetry foundations edge builds on.
|
||||
|
||||
## 2. Core Principle Trace
|
||||
|
||||
Each edge P-rule derives from one or more core C-rules (C1–C8). The
|
||||
matrix extension lands in P4 of the v0.4 plan; the traces below are
|
||||
authoritative. Edge is a broad-derivation domain touching 7 of 8
|
||||
core principles (C1, C3, C4, C5, C6, C7, C8); C2 (Clarity) is not a
|
||||
primary derivation — edge clarity is indirect (a cache with explicit
|
||||
invalidation is clearer than one without, but the primary trace is
|
||||
C1/C3).
|
||||
|
||||
| P-rule | Core | Why |
|
||||
|--------|------|-----|
|
||||
| P1 Proximity is the Design Driver | C4, C1 | Locality of compute near user/data; correctness via latency |
|
||||
| P2 Offline is a First-Class State | C1, C5 | Correctness under partition; reversibility of reconciliation |
|
||||
| P3 Resources are Constrained and Declared | C8, C1 | Economy of constrained nodes; correctness of declared bounds |
|
||||
| P4 Sync Conflicts are Bounded, Not Infinite | C1, C5 | Correctness of convergence; reversibility of divergent state |
|
||||
| P5 Edge Operations are Idempotent | C1 | Correctness under retry |
|
||||
| P6 Cache Invalidation is Explicit | C1, C3 | Correctness of cached state; simplicity of defined invalidation |
|
||||
| P7 Partial Degradation is Engineered | C1, C5 | Correctness of degraded modes; reversibility of recovery |
|
||||
| P8 Geographic Distribution is a First-Class Constraint | C4, C6 | Locality of placement; composability of the fleet |
|
||||
| P9 Identity is Constrained at the Edge | C1, C8 | Correctness via security; economy of trust |
|
||||
| P10 Edge Observability Survives Partition | C7, C5 | Observability of the fleet; reversibility of buffered telemetry |
|
||||
|
||||
## 3. What Violates These Principles
|
||||
|
||||
| Violation | Principle Breached |
|
||||
|-----------|-------------------|
|
||||
| Central-region-only deployment for a latency-bound workload | P1 Proximity is the Design Driver |
|
||||
| App that crashes on disconnect (no offline state) | P2 Offline is a First-Class State |
|
||||
| Undeclared edge-node resource budget (assumes infinite CPU/memory) | P3 Resources are Constrained and Declared |
|
||||
| Sync loop that oscillates forever (CRDT without merge-semantics, LWW without monotonic clock) | P4 Sync Conflicts are Bounded, Not Infinite |
|
||||
| Non-idempotent edge write (cache-fill or device command retried with side effects) | P5 Edge Operations are Idempotent |
|
||||
| TTL-less edge cache under partition (stale-forever, no explicit invalidation) | P6 Cache Invalidation is Explicit |
|
||||
| Crash-on-node-failure (no partial-degradation contract) | P7 Partial Degradation is Engineered |
|
||||
| Random geographic placement (no location-aware routing) | P8 Geographic Distribution is a First-Class Constraint |
|
||||
| Shared edge-device credential (one key for the whole fleet) | P9 Identity is Constrained at the Edge |
|
||||
| Fire-and-forget telemetry (no on-node buffer; data lost on partition) | P10 Edge Observability Survives Partition |
|
||||
| Blocking call on a constrained IoT device with no timeout | P3, P5 (blocks the node; retry unsafe without idempotency) |
|
||||
| Multi-CDN routing with no PoP-selection logic (latency uncontrolled) | P8, P1 (placement not a design decision) |
|
||||
|
||||
## 4. Relationship to Other Domains
|
||||
|
||||
Edge computing is the engineering discipline of placing compute,
|
||||
storage, and data **near the source of generation or consumption**
|
||||
rather than in a centralized cloud. The distinguishing constraints are
|
||||
latency-bound operation, resource-constrained nodes,
|
||||
geo-distribution as a fleet, and partition-prone operation. Edge
|
||||
overlaps three existing domains by *subject* but not by *angle*: per
|
||||
D-061, edge owns the proximity/location/constraint/disconnection
|
||||
concerns that only arise at the network edge. The C4 Locality
|
||||
emphasis is the discriminator: performance's locality is algorithmic
|
||||
(data near compute); edge's locality is geographic (compute near
|
||||
user/data source). Cross-links are one-directional outward (per
|
||||
D-026 extended); no back-link edits to v0.1/v0.2/v0.3 content.
|
||||
|
||||
- `domains/performance/frontend` ← P6 (edge owns geographic,
|
||||
partition-aware cache invalidation; performance owns *generic*
|
||||
caching and measurement — D-061 boundary)
|
||||
- `domains/performance/P4 Resource Bounds` ← P3 (edge owns
|
||||
constrained-device reality; performance owns the generic
|
||||
unbounded-growth-is-a-bug principle)
|
||||
- `domains/observability/metrics` ← P10 (cache-hit ratio, edge
|
||||
telemetry aggregation; edge owns the local-first angle, observability
|
||||
owns generic structured metrics)
|
||||
- `domains/observability/logging` ← P10 (local-first logging buffered
|
||||
on-node and forwarded on reconnect)
|
||||
- `domains/concurrency/patterns` ← P5 (the offline write-queue is
|
||||
the cross-partition analog of the in-process bounded buffer —
|
||||
concurrency owns in-process; edge owns partition-survivable)
|
||||
- `domains/security/secrets` ← P9 (device credentials are scoped,
|
||||
per-device, never shared — edge owns the constrained-identity
|
||||
angle; security owns the general secret hygiene)
|
||||
- `domains/security/input-validation` ← P6 (cache poisoning
|
||||
prevention — edge cache keys are a validation surface)
|
||||
- `domains/data/migrations` ← P4 (schema migration under sync must
|
||||
reconcile across partitioned nodes; data owns the generic migration
|
||||
discipline, edge owns the partitioned-reconcile angle)
|
||||
|
||||
> Note: cross-links to `domains/messaging/` (e.g., MQTT QoS parallels
|
||||
> for delivery semantics) are intentionally omitted here — the
|
||||
> messaging domain is authored in P2. The intra-v0.4 edge↔messaging
|
||||
> links are added in P5 (ATELIER-114 per IDEATE-40) once both
|
||||
> domains exist; the dangling link is acceptable per D-053.
|
||||
@@ -0,0 +1,258 @@
|
||||
# IoT — Derived Rules
|
||||
|
||||
> Derives from `domains/edge/first-principles.md`. Applies P3
|
||||
> (Resources are Constrained and Declared) primarily, with P5
|
||||
> (command idempotency), P7 (partial degradation when devices drop),
|
||||
> P9 (device identity and provisioning), and P10 (telemetry from
|
||||
> devices). Cross-links `domains/security/secrets` for device
|
||||
> credentials and `domains/messaging/queues` for the MQTT QoS
|
||||
> parallels to delivery semantics.
|
||||
|
||||
## What IoT at the Edge Is (P3 Resources are Constrained and Declared)
|
||||
|
||||
- IoT at the edge is the engineering discipline of operating
|
||||
constrained devices — sensors, actuators, gateways, microcontrollers
|
||||
— as first-class participants in a distributed system. The
|
||||
distinguishing constraint is per-device resource bounds (P3): a
|
||||
battery-powered sensor has kilobytes of RAM, a constrained
|
||||
protocol, and a multi-year sleep budget. These constraints are
|
||||
declared per device class, never assumed infinite.
|
||||
- The boundary is per D-061: edge owns the constrained-device
|
||||
reality; performance owns the generic unbounded-growth-is-a-bug
|
||||
principle (`performance/P4 Resource Bounds`); concurrency owns
|
||||
in-process primitives. IoT is an edge concern because its defining
|
||||
traits are constrained resources (P3), geographic distribution as
|
||||
a fleet (P8), partition-prone operation (P2), and device-scoped
|
||||
identity (P9) — concerns that only arise at the network edge.
|
||||
- See `domains/edge/offline-first.md` for the partition-survival
|
||||
discipline that constrained devices depend on, and
|
||||
`domains/edge/sync.md` for the reconciliation of device state
|
||||
across partitions.
|
||||
|
||||
## Device Resource Classes (P3, C8 Economy)
|
||||
|
||||
- A device resource class declares the bounds for a class of
|
||||
devices: CPU (MHz, cores), memory (KB/MB), power (battery mAh,
|
||||
duty-cycle budget), bandwidth (bytes/sec, latency budget), and
|
||||
storage (KB/MB). Every device in the fleet is assigned to a class;
|
||||
every operation is budgeted against its class.
|
||||
- An undeclared budget is a defect (P3 violation): a sensor that
|
||||
sends telemetry every second without a duty-cycle budget exhausts
|
||||
its battery in days, not years. The budget is the correctness
|
||||
bound (C1) and the economy bound (C8).
|
||||
- A device class implies a protocol choice: a class-0 device
|
||||
(constrained sensor, KB RAM) speaks CoAP; a class-1 device
|
||||
(gateway, MB RAM) speaks MQTT; a class-2 device (edge compute
|
||||
node, GB RAM) speaks HTTP. The protocol follows the constraint,
|
||||
not the reverse.
|
||||
|
||||
| Class | RAM | Power | Protocol | Typical role |
|
||||
|-------|-----|-------|----------|--------------|
|
||||
| 0 (constrained sensor) | < 10 KB | Battery, multi-year | CoAP, LoRaWAN | Telemetry only, no inbound commands |
|
||||
| 1 (actuator, gateway) | 10 KB – 1 MB | Battery or wired, weeks-months | MQTT, CoAP | Telemetry + commands, queue-and-forward |
|
||||
| 2 (edge compute) | > 1 MB | Wired, continuous | HTTP, MQTT | Local aggregation, gateway, edge inference |
|
||||
|
||||
## Constrained Protocols — MQTT and CoAP (P3, P5)
|
||||
|
||||
- **MQTT** is the canonical pub/sub protocol for constrained devices.
|
||||
It is lightweight (2-byte header), broker-backed, and provides QoS
|
||||
levels (0, 1, 2) that map to delivery semantics. MQTT is the
|
||||
cross-process analog of message-queue delivery — see
|
||||
`domains/messaging/queues` for the general queue/delivery-semantics
|
||||
discipline; the cross-link is one-directional outward (edge →
|
||||
messaging) per D-062 and D-026 extended.
|
||||
- **CoAP** is the REST analog for constrained devices: UDP-based,
|
||||
low-overhead, with confirmable (CON) and non-confirmable (NON)
|
||||
message types. CoAP fits class-0 devices where TCP is too heavy.
|
||||
- A blocking synchronous call on a constrained device with no
|
||||
timeout is the `blocking-call-on-constrained-device` chaos
|
||||
anti-pattern: it blocks the node, has no timeout (= hang), and
|
||||
retries are unsafe without idempotency (P3 + P5 breach). Every
|
||||
device operation must be async with a timeout, and every retried
|
||||
operation must be idempotent.
|
||||
|
||||
```json
|
||||
// MQTT publish/subscribe payload with QoS levels (P5 idempotency,
|
||||
// P3 constrained protocol).
|
||||
// QoS 0 — at-most-once: fire-and-forget, no ack. For telemetry
|
||||
// where a dropped sample is acceptable (P3 economy of the
|
||||
// constrained link).
|
||||
{
|
||||
"topic": "devices/sensor-7/temperature",
|
||||
"qos": 0,
|
||||
"payload": {
|
||||
"device": "sensor-7",
|
||||
"ts": 1700000000,
|
||||
"value": 21.4,
|
||||
"unit": "C"
|
||||
}
|
||||
}
|
||||
|
||||
// QoS 1 — at-least-once: acked, may duplicate. The consumer must
|
||||
// be idempotent (P5) — dedup by (device, ts) or an idempotency key.
|
||||
{
|
||||
"topic": "devices/actuator-3/command",
|
||||
"qos": 1,
|
||||
"payload": {
|
||||
"device": "actuator-3",
|
||||
"idempotencyKey": "cmd-1700000000-1",
|
||||
"command": "set-point",
|
||||
"value": 22.0
|
||||
}
|
||||
}
|
||||
|
||||
// QoS 2 — exactly-once: four-step handshake, no duplication. The
|
||||
// heaviest QoS; use only where duplicates are intolerable AND the
|
||||
// device has the budget for the handshake (class-1+ only, P3).
|
||||
{
|
||||
"topic": "devices/actuator-3/irreversible-command",
|
||||
"qos": 2,
|
||||
"payload": {
|
||||
"device": "actuator-3",
|
||||
"idempotencyKey": "cmd-1700000000-2",
|
||||
"command": "calibrate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The QoS choice is a P5 (idempotency) and P3 (resource) decision:
|
||||
QoS 0 is cheapest (no ack) but lossy; QoS 1 requires consumer
|
||||
idempotency (P5); QoS 2 is exactly-once but costs a four-step
|
||||
handshake on a constrained link. The default for telemetry is QoS
|
||||
0 or 1; the default for commands is QoS 1 with an idempotency key
|
||||
(P5); QoS 2 is reserved for irreversible commands where the
|
||||
device budget permits.
|
||||
|
||||
## Device Identity and Provisioning (P9 Identity is Constrained at the Edge)
|
||||
|
||||
- Every device holds a per-device identity: a unique device ID and a
|
||||
scoped credential (X.509 certificate, API token, or rotating
|
||||
key). No shared fleet credential — one compromise must not equal
|
||||
a fleet compromise (P9). The credential scope is minimal: a
|
||||
device can publish to `devices/<its-id>/+` and subscribe to
|
||||
`devices/<its-id>/commands`, nothing else.
|
||||
- Provisioning is the act of assigning a device identity at
|
||||
enrollment time. The provisioning manifest declares the device,
|
||||
its class, its allowed topics, and its credential. The manifest is
|
||||
the P9 contract — a device operating outside its manifest scope is
|
||||
a violation.
|
||||
- A device that is provisioned with a shared fleet key (the
|
||||
`shared-edge-device-credential` anti-pattern) is a P9 violation:
|
||||
blast radius is unbounded. See `domains/security/secrets` for the
|
||||
general secret-hygiene discipline (per-identity credentials,
|
||||
rotation, minimal scope) that device provisioning builds on.
|
||||
|
||||
```yaml
|
||||
# Device provisioning manifest (P9 per-device identity + scoped
|
||||
# credentials). The manifest is the contract; the device operates
|
||||
# only within its declared scope.
|
||||
device:
|
||||
id: sensor-7
|
||||
class: 0 # P3 resource class
|
||||
model: temp-sensor-v2
|
||||
firmware: 1.4.2
|
||||
identity:
|
||||
cert: "sha256-of-device-cert"
|
||||
credentialScope:
|
||||
publish:
|
||||
- "devices/sensor-7/temperature"
|
||||
- "devices/sensor-7/status"
|
||||
subscribe:
|
||||
- "devices/sensor-7/commands"
|
||||
# No wildcard, no fleet-wide topics (P9).
|
||||
provisioning:
|
||||
enrolledAt: 2024-01-15T00:00:00Z
|
||||
rotatesEvery: 90d
|
||||
# Per-device credential; never shared (P9, domains/security/secrets).
|
||||
```
|
||||
|
||||
## Telemetry from Devices (P10 Edge Observability Survives Partition)
|
||||
|
||||
- Device telemetry is local-first (P10): the device buffers telemetry
|
||||
on-node and forwards on reconnect. A fire-and-forget telemetry
|
||||
pipeline loses data when the link drops; a buffered pipeline
|
||||
survives. The buffer is bounded by the device class (P3): a
|
||||
class-0 sensor buffers minutes of telemetry, not hours.
|
||||
- Telemetry is observable in aggregate: the operator sees the fleet's
|
||||
behavior, not just per-device. A device that has not reported in
|
||||
its expected interval is itself a signal (a dead device, a
|
||||
partitioned device, a drained battery). See
|
||||
`domains/observability/metrics` for the generic structured-metrics
|
||||
discipline; edge owns the partition-survivable, local-first angle.
|
||||
- Telemetry must not be a secrets channel (P9 analog, see
|
||||
`domains/observability/P6 No Secrets in Observability`): device
|
||||
credentials, PII, and personally-identifying location must not
|
||||
enter telemetry payloads.
|
||||
|
||||
## Command Idempotency (P5 Edge Operations are Idempotent)
|
||||
|
||||
- Device commands are retried by nature (the network is
|
||||
partition-prone). Every command carries an idempotency key so a
|
||||
retried command does not double-apply (P5). A `set-point` command
|
||||
retried with the same idempotency key sets the point once, not
|
||||
twice; an `open-valve` command retried is safe because the valve
|
||||
is already open.
|
||||
- Irreversible commands (a calibration burn-in, a firmware flash)
|
||||
require stronger idempotency: the device tracks applied
|
||||
idempotency keys and refuses re-application. A retried irreversible
|
||||
command without idempotency tracking double-applies the effect
|
||||
(P5 violation, possibly a physical-side-effect bug).
|
||||
- The idempotency key is per-command, not per-device. A device that
|
||||
dedups by device ID alone will drop distinct commands issued in
|
||||
the same window. Use `(device, command-id, ts-window)` or a
|
||||
UUID per command.
|
||||
|
||||
## Partial Degradation When Devices Drop (P7 Partial Degradation is Engineered)
|
||||
|
||||
- A fleet degrades when devices drop (battery exhaustion, partition,
|
||||
hardware failure). The system must continue to operate with the
|
||||
remaining devices; a whole-system crash on one device's failure is
|
||||
a P7 violation. The degraded mode is documented: which
|
||||
aggregations are valid with N-1 devices, which alerts fire, which
|
||||
fallbacks engage.
|
||||
- A device that drops is not an incident by itself — fleets expect
|
||||
churn. The operator-facing signal is the *aggregate* health (X%
|
||||
of devices reporting, Y% partitioned for >Z minutes), not the
|
||||
per-device drop. Per-device drop alerts are noise; aggregate
|
||||
degradation alerts are signal (see `domains/observability/metrics`).
|
||||
- A command to a dropped device must time out (P5 — idempotent
|
||||
retry) and degrade (P7 — the fleet continues without that
|
||||
device). A command that blocks forever waiting for a dropped
|
||||
device is the `blocking-call-on-constrained-device` chaos
|
||||
anti-pattern (P3 + P5 breach).
|
||||
|
||||
## Cross-Link to Messaging (P5, cross-link messaging/queues)
|
||||
|
||||
- MQTT QoS 0/1/2 maps to at-most-once / at-least-once / exactly-once
|
||||
delivery semantics — the same three-way tradeoff documented in
|
||||
`domains/messaging/queues`. The cross-link is one-directional
|
||||
outward (edge → messaging) per D-026 extended: edge owns the
|
||||
constrained-device protocol angle; messaging owns the generic
|
||||
cross-process delivery-semantics angle.
|
||||
- This link dangles until P2 (the messaging domain is authored in
|
||||
P2); it is verified bidirectional in P5 (ATELIER-114 per
|
||||
IDEATE-40). Acceptable per D-053 (vertical-slice integrity — P1
|
||||
ships the edge domain self-consistent; the messaging cross-link
|
||||
resolves by the P6 ship).
|
||||
- The parallel: a constrained device's QoS 1 publish is the
|
||||
device-flavored instance of an at-least-once queue delivery — the
|
||||
consumer (the broker or the downstream service) must be
|
||||
idempotent (P5 here, `messaging/P3 Consumers are Idempotent`
|
||||
there). The idempotency discipline is the same; the protocol and
|
||||
failure model differ (constrained-device link vs broker-backed
|
||||
network).
|
||||
|
||||
## What Violates IoT-at-the-Edge Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| Undeclared device resource budget (assumes infinite battery/RAM) | P3 Resources are Constrained and Declared |
|
||||
| Shared fleet credential (one key for all devices) | P9 Identity is Constrained at the Edge |
|
||||
| Non-idempotent device command (retried command doubles the effect) | P5 Edge Operations are Idempotent |
|
||||
| Blocking synchronous call on a constrained device with no timeout | P3, P5 (blocks the node; retry unsafe) |
|
||||
| Fire-and-forget telemetry with no on-device buffer (lost on partition) | P10 Edge Observability Survives Partition |
|
||||
| Whole-system crash on one device's failure (no degradation contract) | P7 Partial Degradation is Engineered |
|
||||
| Device credential scope that includes fleet-wide topics (over-scoped) | P9, `domains/security/secrets` |
|
||||
| QoS 2 used on a class-0 device (no budget for the handshake) | P3 Resources are Constrained and Declared |
|
||||
| Per-device-drop alert (noise; aggregate degradation is the signal) | P7, `domains/observability/metrics` |
|
||||
| Telemetry payload that includes device credentials or PII | P9, `domains/observability/P6 No Secrets in Observability` |
|
||||
@@ -0,0 +1,354 @@
|
||||
# Offline-First — Derived Rules
|
||||
|
||||
> Derives from `domains/edge/first-principles.md`. Applies P2
|
||||
> (Offline is a First-Class State) primarily, with P5 (idempotent
|
||||
> queue-and-forward), P4 (bounded sync conflicts on reconnect), P7
|
||||
> (partial degradation), and P10 (local-first telemetry). Cross-links
|
||||
> `domains/concurrency/patterns` for the in-process bounded-buffer
|
||||
> analog and `domains/observability/logging` for local-first logging.
|
||||
|
||||
## What Offline-First Is (P2 Offline is a First-Class State)
|
||||
|
||||
- Offline-first is the design discipline in which the system
|
||||
continues to operate when disconnected from the center. Partition
|
||||
is the norm, not the exception; reconciliation happens on
|
||||
reconnect. The offline state is engineered, not a degenerate mode
|
||||
the app falls into by accident.
|
||||
- The boundary is per D-061: edge owns the
|
||||
proximity/location/disconnection angle. An offline-first web app
|
||||
is an edge concern because its defining trait is partition-survival
|
||||
(P2), not generic performance. The local-first storage is the
|
||||
edge device's constrained-resource reality (P3).
|
||||
- Offline-first is the precondition for the bounded-conflict
|
||||
discipline of `P4 Sync Conflicts are Bounded, Not Infinite`:
|
||||
without offline operation there is nothing to reconcile; with it,
|
||||
the reconnect reconciliation is the correctness mechanism. See
|
||||
`domains/edge/sync.md` for the conflict-resolution strategies.
|
||||
|
||||
## Local-First Storage (P2, P3)
|
||||
|
||||
- Local-first storage holds the working copy on the device:
|
||||
IndexedDB (browser), SQLite (mobile, embedded), or on-device file
|
||||
storage (desktop, IoT gateway). The local store is the authority
|
||||
while offline; the server is reconciled later, not consulted per
|
||||
read.
|
||||
- The local store is bounded by the device (P3 — Resources are
|
||||
Constrained and Declared). A local store that grows without bound
|
||||
is a defect: declare a budget (e.g., a 50 MB IndexedDB quota, a
|
||||
30-day rolling window), and evict outside the budget deterministically.
|
||||
- The local store is the offline state; without it the app is
|
||||
online-only and crashes on disconnect (P2 violation). The store is
|
||||
the reversibility mechanism (C5): every local write is reversible
|
||||
on reconcile.
|
||||
|
||||
```typescript
|
||||
// Local-first store sketch (IndexedDB). The app reads from the
|
||||
// local store, never the network, while offline. Writes queue
|
||||
// locally and forward on reconnect (P2, P5).
|
||||
const db = await openDB("atelier-offline", 1, {
|
||||
upgrade(db) {
|
||||
const store = db.createObjectStore("pending-writes", {
|
||||
keyPath: "id",
|
||||
});
|
||||
store.createIndex("by-createdAt", "createdAt");
|
||||
},
|
||||
});
|
||||
|
||||
async function readRecord(id: string) {
|
||||
// Read from local store first; the network is a reconcile path,
|
||||
// not the read path.
|
||||
return db.get("pending-writes", id);
|
||||
}
|
||||
```
|
||||
|
||||
## Queue-and-Forward for Writes (P5 Edge Operations are Idempotent)
|
||||
|
||||
- Every write while offline is queued locally and forwarded to the
|
||||
server on reconnect. The queue is the offline write-queue; the
|
||||
forward is the reconcile. Each queued write carries an idempotency
|
||||
key so a retried forward (the network is partition-prone) does not
|
||||
double-apply (P5).
|
||||
- The queue is bounded (P3): a queue that grows without limit on a
|
||||
constrained device will exhaust it. Declare a max-queue-depth and
|
||||
a max-queue-bytes; reject or evict beyond the bound with a defined
|
||||
policy (oldest-first, lowest-priority-first).
|
||||
- The queue is the cross-partition analog of the in-process bounded
|
||||
buffer — see `domains/concurrency/patterns` (bounded buffer,
|
||||
backpressure). Concurrency owns the in-process analog; edge owns
|
||||
the partition-survivable analog. The failure model differs: the
|
||||
in-process buffer fails by OOM; the offline write-queue fails by
|
||||
partition or device loss.
|
||||
|
||||
```typescript
|
||||
// Offline write-queue sketch. Each entry carries an idempotency
|
||||
// key (P5) so a retried forward is safe. The queue is bounded by
|
||||
// maxDepth (P3).
|
||||
interface PendingWrite {
|
||||
id: string; // local id
|
||||
idempotencyKey: string; // server-side dedup key (P5)
|
||||
collection: string;
|
||||
payload: unknown;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 1000;
|
||||
|
||||
async function queueWrite(write: Omit<PendingWrite, "id" | "idempotencyKey" | "createdAt">) {
|
||||
const depth = await db.count("pending-writes");
|
||||
if (depth >= MAX_DEPTH) {
|
||||
// P3: bounded queue. Evict the oldest pending write or reject.
|
||||
// Rejecting is correct when the write is higher-priority than
|
||||
// the oldest; evicting is correct when the newest is lowest.
|
||||
throw new Error("offline-queue-full");
|
||||
}
|
||||
const entry: PendingWrite = {
|
||||
...write,
|
||||
id: crypto.randomUUID(),
|
||||
idempotencyKey: `${write.collection}:${crypto.randomUUID()}`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await db.put("pending-writes", entry);
|
||||
// The forward loop picks this up when connectivity returns.
|
||||
}
|
||||
|
||||
async function forwardPendingWrites(server: Server) {
|
||||
const pending = await db.getAllFromIndex("pending-writes", "by-createdAt");
|
||||
for (const write of pending) {
|
||||
// P5: idempotent — the server dedups by idempotencyKey.
|
||||
await server.apply(write, write.idempotencyKey);
|
||||
await db.delete("pending-writes", write.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Conflict Detection on Reconnect (P4 Sync Conflicts are Bounded)
|
||||
|
||||
- On reconnect, the queued writes are forwarded; the server may
|
||||
have advanced while the device was offline. A conflict is when the
|
||||
local write and the server state diverge. Conflict detection is
|
||||
the precondition for bounded reconciliation (P4): a write forwarded
|
||||
blindly (last-write-wins with no clock) is a P4 violation waiting
|
||||
to happen.
|
||||
- Conflict resolution strategies (CRDT, LWW with vector clocks,
|
||||
application-specific merge) are the subject of
|
||||
`domains/edge/sync.md` — the CRDT-vs-LWW decision matrix there
|
||||
determines which applies. Offline-first owns the *detection*; sync
|
||||
owns the *resolution*.
|
||||
- A reconnect that detects no conflicts when conflicts exist is a
|
||||
silent correctness defect (C1, P4). Detection must be conservative:
|
||||
when in doubt, flag a conflict and surface it to the merge
|
||||
function or the user.
|
||||
|
||||
## UI for Offline State (P7 Partial Degradation is Engineered)
|
||||
|
||||
- The UI must reflect the offline state visibly: a "you are offline,
|
||||
changes will sync when connected" banner, a pending-writes counter,
|
||||
a last-synced timestamp. A UI that hides the offline state
|
||||
violates P7 — the degraded mode is a designed state with a defined
|
||||
contract, not a silent fall-through.
|
||||
- The UI must function while offline: reads from local-first
|
||||
storage, writes to the queue, navigation that does not require the
|
||||
network. An app that shows a blank screen or a spinner-forever when
|
||||
offline has no offline state (P2 violation) and no degradation
|
||||
contract (P7 violation).
|
||||
- The pending-writes counter is the local-first analog of the
|
||||
messaging consumer-lag metric — see
|
||||
`domains/observability/metrics` for the lag-discipline parallel.
|
||||
|
||||
## Service Workers (P2, P6)
|
||||
|
||||
- A service worker is a client-side proxy that intercepts network
|
||||
requests and serves from a local cache. It is the browser's
|
||||
offline-first primitive: the service worker cache is the
|
||||
offline-capable store for assets; the IndexedDB store is the
|
||||
offline-capable store for data.
|
||||
- The service worker cache is an edge cache (P6 — Cache Invalidation
|
||||
is Explicit): it must carry a TTL or explicit invalidation
|
||||
strategy. A service worker that caches forever and never
|
||||
invalidates is a TTL-less edge cache under partition — a P6
|
||||
violation (stale-forever).
|
||||
- See `domains/edge/cdn.md` for the generic edge-cache invalidation
|
||||
discipline; the service worker is the on-device instance of it.
|
||||
|
||||
```javascript
|
||||
// Service worker cache strategy: stale-while-revalidate for
|
||||
// assets, network-first for data, explicit version-bump for
|
||||
// breaking changes (P6).
|
||||
const CACHE = "atelier-v3"; // bump on deploy to invalidate (P6)
|
||||
const ASSETS = ["/", "/app.js", "/styles.css"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
// Network-first for data; fall back to cache on partition (P2).
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
} else {
|
||||
// Stale-while-revalidate for assets (P6 explicit invalidation).
|
||||
event.respondWith(
|
||||
caches.open(CACHE).then(async (cache) => {
|
||||
const cached = await cache.match(event.request);
|
||||
const network = fetch(event.request).then((resp) => {
|
||||
cache.put(event.request, resp.clone());
|
||||
return resp;
|
||||
}).catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
// P6: explicit invalidation. Drop old caches on activate.
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Offline Write-Queue and Conflict Detection Mapped to the Testing Pyramid (IDEATE-38, ATELIER-94)
|
||||
|
||||
The offline write-queue and conflict-detection patterns must be
|
||||
tested at every tier of the testing pyramid. Each tier exercises a
|
||||
different failure mode; skipping a tier leaves a correctness gap
|
||||
(P2, P4 violations that surface only in production partitions).
|
||||
|
||||
| Pyramid Tier | What it exercises | What it proves |
|
||||
|-------------|-------------------|----------------|
|
||||
| **Unit** | Conflict detection on a merge function (pure inputs → expected merge result) | The merge logic is correct in isolation (P4) — given two divergent states, the merge returns the converged state |
|
||||
| **Integration** | Reconnect reconcile against a local store (fake server, real IndexedDB/SQLite) | The queue-and-forward loop drains correctly; the local store and server converge after reconnect (P2, P5) |
|
||||
| **E2e** | Partition simulation with a fake network (the app runs in a browser, the network is cut and restored) | The offline state, UI, and reconcile work end-to-end under partition (P2, P7) |
|
||||
|
||||
- **Unit — conflict detection on a merge function.** The merge
|
||||
function is pure: given two divergent states and a clock, it
|
||||
returns the converged state. Test every merge case (LWW, CRDT
|
||||
register, set union, application-specific three-way merge) as a
|
||||
pure function. This is the cheapest tier and the highest coverage
|
||||
per test — see `domains/testing/pyramid`.
|
||||
|
||||
```typescript
|
||||
// Unit test sketch: conflict detection on a merge function (P4).
|
||||
// The merge function is pure; no network, no store. Test that
|
||||
// divergent states converge and that the merge is bounded (no
|
||||
// oscillation).
|
||||
|
||||
function mergeLWW(local: State, remote: State, clock: Clock): State {
|
||||
// Last-write-wins: the state with the later vector-clock wins.
|
||||
// Returns the converged state (P4).
|
||||
return clock.compare(local.clock, remote.clock) >= 0 ? local : remote;
|
||||
}
|
||||
|
||||
// Unit cases:
|
||||
// - local ahead → local wins
|
||||
// - remote ahead → remote wins
|
||||
// - concurrent (clocks incomparable) → conflict flagged or LWW tiebreak
|
||||
// - identical → no-op convergence (bounded, no oscillation)
|
||||
test("mergeLWW converges when local is ahead", () => {
|
||||
const local = { v: 2, clock: { a: 2 } };
|
||||
const remote = { v: 1, clock: { a: 1 } };
|
||||
expect(mergeLWW(local, remote, { compare: (a, b) => a.a - b.a })).toEqual(local);
|
||||
});
|
||||
```
|
||||
|
||||
- **Integration — reconnect reconcile against a local store.** A
|
||||
fake server stands in for the network; the real IndexedDB (or
|
||||
SQLite) holds the queue. The test fills the queue while offline,
|
||||
reconnects, and asserts the queue drains and the server and local
|
||||
store converge. This exercises the queue-and-forward loop (P5)
|
||||
and the reconcile against real storage.
|
||||
|
||||
```typescript
|
||||
// Integration test sketch: reconnect reconcile against a local
|
||||
// store. A fake server; real IndexedDB. The queue drains; the
|
||||
// server and local store converge after reconnect (P2, P5).
|
||||
|
||||
test("reconnect reconciles pending writes against the server", async () => {
|
||||
const db = await openDB("test-offline", 1, { /* schema */ });
|
||||
const server = new FakeServer();
|
||||
await queueWrite(db, { collection: "docs", payload: { v: 1 } });
|
||||
// Simulate offline: server is unreachable.
|
||||
server.offline();
|
||||
await queueWrite(db, { collection: "docs", payload: { v: 2 } });
|
||||
expect(await db.count("pending-writes")).toBe(2);
|
||||
// Simulate reconnect: server is reachable.
|
||||
server.online();
|
||||
await forwardPendingWrites(server, db);
|
||||
expect(await db.count("pending-writes")).toBe(0);
|
||||
expect(await server.latest("docs")).toEqual({ v: 2 });
|
||||
});
|
||||
```
|
||||
|
||||
- **E2e — partition simulation with a fake network.** The app runs
|
||||
in a real browser; a fake network layer cuts and restores the
|
||||
connection. The test asserts the UI shows the offline state, the
|
||||
writes queue, the reconnect reconciles, and the UI returns to
|
||||
online. This is the highest-fidelity tier and the lowest coverage
|
||||
per test — run a small number of representative scenarios, not a
|
||||
combinatorial matrix.
|
||||
|
||||
```typescript
|
||||
// E2e test sketch: partition simulation with a fake network. The
|
||||
// app runs in a browser; the network is cut and restored. Asserts
|
||||
// the offline UI state, the queue, the reconcile, and the online
|
||||
// recovery (P2, P7).
|
||||
|
||||
test("app survives a network partition and reconciles on reconnect", async () => {
|
||||
await page.goto("https://app.example.com");
|
||||
await page.click("text=Edit document");
|
||||
await page.fill("textarea", "offline edit");
|
||||
// Cut the network.
|
||||
await page.setOffline(true);
|
||||
await page.click("text=Save");
|
||||
await expect(page.locator("text=You are offline")).toBeVisible();
|
||||
await expect(page.locator("text=1 pending change")).toBeVisible();
|
||||
// Restore the network.
|
||||
await page.setOffline(false);
|
||||
await expect(page.locator("text=All changes synced")).toBeVisible();
|
||||
await expect(page.locator("text=0 pending changes")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- The three tiers are complementary: unit proves the merge logic,
|
||||
integration proves the reconcile loop, e2e proves the partition
|
||||
behavior. Skipping any tier leaves a correctness gap. See
|
||||
`domains/testing/pyramid` for the pyramid discipline and
|
||||
`domains/testing/fixtures` for the fake-server and fake-network
|
||||
fixture patterns.
|
||||
|
||||
## Observability (P10 Edge Observability Survives Partition)
|
||||
|
||||
- The offline state is itself an observable signal: the
|
||||
pending-writes count, the last-synced timestamp, the
|
||||
reconcile-failure count. A device stuck offline for days with a
|
||||
full queue is an incident; without local-first telemetry it is
|
||||
invisible (P10 violation).
|
||||
- Local-first logging (buffered on-device, forwarded on reconnect)
|
||||
is the offline-first instance of `P10 Edge Observability Survives
|
||||
Partition`. See `domains/observability/logging` for the generic
|
||||
structured-logging discipline the local-first buffer builds on.
|
||||
- A reconcile failure that is not logged locally is a silent defect
|
||||
— the operator cannot debug what they cannot see (C7, P10).
|
||||
|
||||
## What Violates Offline-First Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| App that crashes on disconnect (no offline state) | P2 Offline is a First-Class State |
|
||||
| Unbounded offline write-queue (grows until device OOM) | P3 Resources are Constrained and Declared |
|
||||
| Queued write forwarded without an idempotency key (retry doubles the effect) | P5 Edge Operations are Idempotent |
|
||||
| Reconnect that detects no conflicts when conflicts exist | P4 Sync Conflicts are Bounded, Not Infinite |
|
||||
| UI that hides the offline state (no banner, no pending counter) | P7 Partial Degradation is Engineered |
|
||||
| Service worker cache with no TTL and no explicit invalidation | P6 Cache Invalidation is Explicit |
|
||||
| Reconcile failure with no local log (silent under partition) | P10 Edge Observability Survives Partition |
|
||||
| Merge function that oscillates (no convergence guarantee) | P4, `domains/edge/sync.md` |
|
||||
| Local-first store with no declared budget (grows without bound) | P3, `domains/concurrency/patterns` (bounded buffer analog) |
|
||||
| E2e tests that never simulate a partition (offline path untested) | P2, `domains/testing/pyramid` |
|
||||
@@ -0,0 +1,315 @@
|
||||
# Sync — Derived Rules
|
||||
|
||||
> Derives from `domains/edge/first-principles.md`. Applies P4 (Sync
|
||||
> Conflicts are Bounded, Not Infinite) primarily, with P5
|
||||
> (idempotent merge operations), P2 (offline as the precondition),
|
||||
> and P10 (sync is observable). Cross-links `domains/data/migrations`
|
||||
> for schema migration under sync and `domains/concurrency/patterns`
|
||||
> for the immutability-aid-merge principle.
|
||||
|
||||
## What the Sync Problem Is (P4 Sync Conflicts are Bounded, Not Infinite)
|
||||
|
||||
- Sync is the discipline of reconciling divergent state across
|
||||
partitioned nodes. While partitioned, each node accepts writes
|
||||
independently; on reconnect, the divergent state must converge.
|
||||
The correctness contract is that the merge terminates and
|
||||
converges — oscillation and infinite sync loops are correctness
|
||||
failures, not eventual consistency (P4).
|
||||
- Sync is the edge domain's deepest problem: it is the
|
||||
reconciliation layer above `P2 Offline is a First-Class State`.
|
||||
Without offline operation there is nothing to sync; with it, the
|
||||
reconnect reconciliation is the correctness mechanism. See
|
||||
`domains/edge/offline-first.md` for the offline write-queue that
|
||||
produces the divergent state to be reconciled.
|
||||
- The boundary is per D-061: edge owns the partitioned-reconcile
|
||||
angle; concurrency owns the in-process analog
|
||||
(`concurrency/P1 Immutability by Default` — immutability aids
|
||||
merge); data owns the generic migration discipline
|
||||
(`data/migrations`). Sync is an edge concern because its defining
|
||||
trait is partitioned divergence, a concern that only arises at the
|
||||
network edge.
|
||||
|
||||
## Conflict Resolution Strategies (P4, C1 Correctness, C5 Reversibility)
|
||||
|
||||
- A conflict is when two nodes have divergent state for the same
|
||||
logical entity and no total order determines which is correct.
|
||||
Resolution strategies fall into two families:
|
||||
- **Conflict-free**: the data type guarantees convergence by
|
||||
construction (CRDTs). The merge is deterministic; no conflict
|
||||
surfaces to the user or the application.
|
||||
- **Conflict-tolerant**: the data type can conflict; the
|
||||
resolution policy (last-write-win, three-way merge,
|
||||
application-specific) arbitrates. Conflicts may surface to the
|
||||
user or be silently resolved per a documented policy.
|
||||
- The choice is a P4 decision: conflict-free types guarantee the
|
||||
bound (convergence) but constrain the data model; conflict-tolerant
|
||||
types are flexible but require the resolution policy to be correct
|
||||
and bounded (no oscillation). See the decision matrix below.
|
||||
|
||||
## CRDTs — Conflict-Free Replicated Data Types (P4, C5, C6)
|
||||
|
||||
- A CRDT is a data type whose merge operation is associative,
|
||||
commutative, and idempotent. Given any set of divergent replicas,
|
||||
merging them in any order converges to the same state — the merge
|
||||
is deterministic and terminating (P4 bound). CRDTs derive from C5
|
||||
Reversibility (divergent state reverses to convergence) and C6
|
||||
Composability (CRDTs compose: a CRDT map of CRDT registers is
|
||||
itself a CRDT).
|
||||
- **State-based (CvRDT — convergent):** each replica carries its
|
||||
full state; merge is a least-upper-bound on a semi-lattice. The
|
||||
payload is larger (full state per merge); the merge is simple
|
||||
(one function). Fits small state and unreliable networks.
|
||||
- **Operation-based (CmRDT — commutative):** each replica carries
|
||||
operations; merge is applying the operations in causal order. The
|
||||
payload is smaller (ops, not state); the delivery must be
|
||||
reliable and causally ordered. Fits large state and reliable
|
||||
transport.
|
||||
- The tradeoff: state-based is simpler but heavier; operation-based
|
||||
is lighter but requires causal delivery. Both guarantee
|
||||
convergence (P4); the choice is a C8 Economy decision (bandwidth
|
||||
vs delivery complexity).
|
||||
|
||||
```typescript
|
||||
// CRDT register: LWW-element-set (state-based, CvRDT). The merge
|
||||
// is deterministic — the register with the later timestamp wins.
|
||||
// Convergence is guaranteed (P4); the merge is idempotent (P5).
|
||||
|
||||
interface LWWRegister<T> {
|
||||
value: T;
|
||||
timestamp: number; // monotonic clock; ties broken by node id
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
function mergeLWWRegister<T>(
|
||||
local: LWWRegister<T>,
|
||||
remote: LWWRegister<T>,
|
||||
): LWWRegister<T> {
|
||||
// The merge is associative, commutative, idempotent (P4, P5).
|
||||
// (local.timestamp, local.nodeId) > (remote.timestamp, remote.nodeId)
|
||||
// is a total order — no oscillation, no infinite loop.
|
||||
if (local.timestamp > remote.timestamp) return local;
|
||||
if (local.timestamp < remote.timestamp) return remote;
|
||||
// Tie: break by node id for a deterministic total order.
|
||||
return local.nodeId > remote.nodeId ? local : remote;
|
||||
}
|
||||
|
||||
// The register is a CRDT: merge(merge(a, b), c) === merge(a, merge(b, c))
|
||||
// for any replicas a, b, c. Convergence is guaranteed (P4).
|
||||
```
|
||||
|
||||
```typescript
|
||||
// CRDT set: add-wins last-write-wins element set (state-based).
|
||||
// Each element carries a timestamp; remove only wins if the
|
||||
// remove-timestamp is later than the add-timestamp. This avoids
|
||||
// the remove-wins-vs-add race (P4) without surfacing a conflict.
|
||||
|
||||
interface AWLWWSet<T> {
|
||||
adds: Map<T, number>; // element -> add-timestamp
|
||||
removes: Map<T, number>; // element -> remove-timestamp
|
||||
}
|
||||
|
||||
function mergeAWLWWSet<T>(a: AWLWWSet<T>, b: AWLWWSet<T>): AWLWWSet<T> {
|
||||
const adds = new Map<T, number>(a.adds);
|
||||
const removes = new Map<T, number>(a.removes);
|
||||
for (const [el, ts] of b.adds) {
|
||||
adds.set(el, Math.max(adds.get(el) ?? 0, ts)); // add-wins union
|
||||
}
|
||||
for (const [el, ts] of b.removes) {
|
||||
removes.set(el, Math.max(removes.get(el) ?? 0, ts));
|
||||
}
|
||||
return { adds, removes };
|
||||
}
|
||||
|
||||
function contains<T>(set: AWLWWSet<T>, el: T): boolean {
|
||||
const addTs = set.adds.get(el) ?? 0;
|
||||
const rmTs = set.removes.get(el) ?? 0;
|
||||
return addTs > rmTs; // add wins on equal timestamp (P4 bounded)
|
||||
}
|
||||
```
|
||||
|
||||
## Last-Write-Win (LWW) with Vector Clocks (P4, C1, C5)
|
||||
|
||||
- LWW is the simplest conflict-tolerant strategy: the write with the
|
||||
latest timestamp wins. It is cheap, but it silently discards
|
||||
concurrent writes — the "lost update" is the correctness cost. LWW
|
||||
is correct only when the timestamp is a total order (a monotonic
|
||||
clock, not wall time), and when lost concurrent writes are
|
||||
acceptable (e.g., caching, presence, ephemeral state).
|
||||
- **Vector clocks** are the timestamp that knows about concurrency.
|
||||
A vector clock records the logical time of each node; two writes
|
||||
are concurrent iff neither vector dominates the other. LWW with
|
||||
vector clocks: a write that is causally later wins; a write that
|
||||
is concurrent conflicts and is resolved by a tiebreak (node id,
|
||||
wall time, or application policy).
|
||||
- The tiebreak is the P4 bound: the conflict must be resolved
|
||||
deterministically (no oscillation) and the resolution must be
|
||||
documented. A tiebreak by wall time alone (no vector clock) is a
|
||||
P4 violation waiting to happen — wall time skews across nodes,
|
||||
and a clock skew can flip the tiebreak, oscillating the merge.
|
||||
|
||||
```typescript
|
||||
// LWW with vector clocks (conflict-tolerant, P4 bounded). The
|
||||
// vector clock records causal order; concurrent writes conflict;
|
||||
// the conflict is tiebroken deterministically (no oscillation).
|
||||
|
||||
type VectorClock = Record<string, number>; // nodeId -> counter
|
||||
|
||||
function compareClock(a: VectorClock, b: VectorClock): "before" | "after" | "equal" | "concurrent" {
|
||||
let aBefore = false, bBefore = false;
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const k of keys) {
|
||||
const av = a[k] ?? 0;
|
||||
const bv = b[k] ?? 0;
|
||||
if (av < bv) aBefore = true;
|
||||
if (av > bv) bBefore = true;
|
||||
}
|
||||
if (aBefore && bBefore) return "concurrent";
|
||||
if (aBefore) return "before";
|
||||
if (bBefore) return "after";
|
||||
return "equal";
|
||||
}
|
||||
|
||||
interface LWWVectorState<T> {
|
||||
value: T;
|
||||
clock: VectorClock;
|
||||
writerId: string; // tiebreak: deterministic, no oscillation (P4)
|
||||
}
|
||||
|
||||
function mergeLWWVector<T>(
|
||||
local: LWWVectorState<T>,
|
||||
remote: LWWVectorState<T>,
|
||||
): LWWVectorState<T> {
|
||||
const order = compareClock(local.clock, remote.clock);
|
||||
if (order === "before") return remote; // remote causally later
|
||||
if (order === "after" || order === "equal") return local;
|
||||
// Concurrent: tiebreak by writer id (deterministic, P4 bounded).
|
||||
return local.writerId > remote.writerId ? local : remote;
|
||||
}
|
||||
```
|
||||
|
||||
- The merge is idempotent (P5): merging the same two replicas twice
|
||||
yields the same result. The tiebreak by `writerId` is a total
|
||||
order, so the merge cannot oscillate (P4 bound).
|
||||
- A vector-clock merge that surfaces the concurrent conflict to the
|
||||
application (instead of tiebreaking) is also valid — the
|
||||
application resolves per its own policy. The P4 bound is that the
|
||||
resolution terminates; the policy determines whether the user sees
|
||||
the conflict or the system silences it.
|
||||
|
||||
## Merge Semantics (P4, P5, cross-link concurrency/patterns)
|
||||
|
||||
- The merge function is the heart of sync. Its properties (P4):
|
||||
- **Associative**: `merge(merge(a, b), c) === merge(a, merge(b, c))`.
|
||||
- **Commutative**: `merge(a, b) === merge(b, a)`.
|
||||
- **Idempotent**: `merge(a, a) === a` (P5 — retried merges are safe).
|
||||
- Immutability aids merge: an immutable state representation (the
|
||||
CRDT payload, the LWW register with a clock) makes the merge a
|
||||
pure function of two inputs, with no in-place mutation race. See
|
||||
`domains/concurrency/patterns` (`concurrency/P1 Immutability by
|
||||
Default`) for the in-process immutability principle; sync is the
|
||||
cross-partition instance of it.
|
||||
- A merge that mutates in place is a P5 violation waiting to
|
||||
happen: a retried merge mutates the same state twice, and the
|
||||
result is not idempotent. Always merge into a new state; never
|
||||
mutate the inputs.
|
||||
|
||||
## Conflict-Free vs Conflict-Tolerant Data Types (P4, C3 Simplicity)
|
||||
|
||||
- **Conflict-free (CRDTs):** the data type guarantees convergence.
|
||||
The application never sees a conflict; the merge is deterministic.
|
||||
The cost: the data model is constrained (counters, sets, registers,
|
||||
maps of these). A conflict-free type for arbitrary JSON is hard;
|
||||
a conflict-free type for a counter is a PN-counter.
|
||||
- **Conflict-tolerant (LWW, three-way merge, application policy):**
|
||||
the data type can conflict; the resolution policy arbitrates. The
|
||||
cost: the policy must be correct and bounded (no oscillation), and
|
||||
the conflict may surface to the user. The benefit: any data model
|
||||
can be made conflict-tolerant (just pick a tiebreak).
|
||||
- The choice is the decision matrix below. It is a P4 decision
|
||||
(which bound), a C1 decision (which correctness cost is
|
||||
acceptable), and a C3 decision (which simplicity is affordable).
|
||||
See also `domains/data/migrations` for the schema-evolution angle
|
||||
— a schema change under sync must be compatible with both
|
||||
replicas, or the merge fails on the new shape.
|
||||
|
||||
## Schema Migration Under Sync (P4, cross-link data/migrations)
|
||||
|
||||
- A schema migration under sync is harder than a single-node
|
||||
migration: both replicas must understand the new shape, or the
|
||||
merge fails. The migration must be forward-and-backward compatible
|
||||
across all replicas that may still hold the old shape — see
|
||||
`domains/data/migrations` for the generic compatibility discipline.
|
||||
- A breaking schema change under sync requires a staged migration:
|
||||
deploy the new-shape-aware merge first (it accepts both shapes),
|
||||
then deploy the new shape, then deploy the old-shape-removing
|
||||
merge. A big-bang schema change under sync is a P4 violation: the
|
||||
replicas that have not yet upgraded will fail the merge, and the
|
||||
sync will not converge.
|
||||
- The merge function's version awareness is the P4 bound: the merge
|
||||
must handle every shape version that may exist in the fleet, or
|
||||
reject (and surface) the merge rather than silently corrupting.
|
||||
|
||||
## CRDT vs Last-Write-Win — Decision Matrix (D-069)
|
||||
|
||||
| Strategy | When | Correctness Guarantee | Operational Cost | Failure Mode |
|
||||
|----------|------|------------------------|-------------------|--------------|
|
||||
| CRDT (state-based, CvRDT) | The data model fits a CRDT (counter, set, register, map of these); convergence must be guaranteed without surfacing conflicts; the network is unreliable (full-state merge tolerates dropped ops) | Strong eventual convergence — `merge(a, b) === merge(b, a)` for any replicas (P4 bound by construction) | Medium — full state per merge (bandwidth); semi-lattice merge function per type; CRDT library or hand-rolled | A bug in the merge function = silent divergence (C1); large state = bandwidth cost on constrained links (P3) |
|
||||
| CRDT (operation-based, CmRDT) | The data model fits a CRDT; bandwidth is constrained (ops are smaller than state); the transport is reliable and causally ordered | Strong eventual convergence — same guarantee, smaller payload | High — requires causal delivery (vector clock or broker with ordering); op transform must be idempotent (P5) | Causal-delivery violation = lost ops = divergence; op-transform bug = silent divergence |
|
||||
| Last-Write-Win (LWW) with vector clocks | The data model is arbitrary (any JSON, any record); concurrent writes are acceptable to discard or tiebreak; a total order tiebreak (node id) is acceptable | Bounded convergence — causally-later writes win; concurrent writes are tiebroken deterministically (P4 bound via tiebreak) | Low — simple merge (compare clocks, pick winner); no CRDT library; small payload | Concurrent writes are silently discarded (lost update); tiebreak by wall time = clock-skew oscillation (P4 violation); no vector clock = no concurrent-write detection = silent loss |
|
||||
| LWW with wall-clock timestamp only | The data model is ephemeral (cache, presence); lost updates are acceptable; the clock is roughly synchronized (NTP) | Weak — convergence eventually, but concurrent writes may oscillate with clock skew; no concurrent-write detection | Lowest — one timestamp per write; no clock vector | Clock skew = oscillation (P4 violation); concurrent writes silently lost; not a correctness-safe strategy for durable state |
|
||||
| Three-way merge (application-specific) | The data model is structured (documents, forms); conflicts should surface to the user or a domain-specific resolver; the merge is field-level | Bounded if the merge function is correct (associative, commutative, idempotent — P4, P5); conflicts surface per field | High — application-specific merge function per type; UI for conflict resolution; user-facing conflict surface | Merge-function bug = silent divergence or oscillation; unbounded conflict UI = user fatigue |
|
||||
|
||||
- The default for structured state that must converge silently is a
|
||||
**CRDT** (state-based for unreliable networks, operation-based for
|
||||
bandwidth-constrained reliable transport). The default for
|
||||
arbitrary JSON where lost concurrent updates are acceptable is
|
||||
**LWW with vector clocks** (never wall-clock-only for durable
|
||||
state). The default for user-facing documents where conflicts
|
||||
should surface is **three-way merge** with a documented resolution
|
||||
policy.
|
||||
- The failure-mode column is the P4 check: every row except
|
||||
wall-clock-only LWW carries a bounded failure mode (the bug is in
|
||||
the implementation, not the strategy). Wall-clock-only LWW carries
|
||||
an unbounded failure mode (clock skew = oscillation) and is a P4
|
||||
violation for durable state. Use it only for ephemeral state
|
||||
where lost updates are acceptable.
|
||||
- The choice is a P4 decision (which bound) and a C1 decision
|
||||
(which correctness cost). A CRDT guarantees convergence but
|
||||
constrains the data model; LWW is flexible but discards concurrent
|
||||
writes. Neither is universally correct; the matrix is the
|
||||
decision tool.
|
||||
|
||||
## Observability of Sync (P10 Edge Observability Survives Partition)
|
||||
|
||||
- Sync is itself an observable operation: the merge count, the
|
||||
conflict count, the convergence lag (time from reconnect to
|
||||
convergence), and the divergent-replica count are first-class
|
||||
signals. A sync that runs forever without converging is the
|
||||
`edge-sync-loop` chaos anti-pattern (P4 breach); without
|
||||
observability it is invisible until the user notices the stale
|
||||
state.
|
||||
- A conflict that is silently resolved should be logged (the
|
||||
resolution policy applied, the discarded write's idempotency key,
|
||||
the winning write's clock). A conflict that surfaces to the user
|
||||
should be metricated (the conflict rate, the resolution time).
|
||||
See `domains/observability/metrics` for the generic discipline.
|
||||
- A divergent replica that has not converged after the expected
|
||||
window is an incident; without a metric it is invisible (P10
|
||||
breach). Wire sync convergence to an alert — the
|
||||
`divergent-replica-count` is the sync analog of the messaging
|
||||
`consumer-lag` metric.
|
||||
|
||||
## What Violates Sync Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| Sync loop that oscillates forever (CRDT without merge-semantics, LWW without monotonic clock) | P4 Sync Conflicts are Bounded, Not Infinite |
|
||||
| LWW with wall-clock timestamp only on durable state (clock skew = oscillation) | P4, C1 (no concurrent-write detection) |
|
||||
| Merge function that mutates inputs in place (retried merge is not idempotent) | P5 Edge Operations are Idempotent, `domains/concurrency/patterns` |
|
||||
| Big-bang schema change under sync (replicas fail the merge) | P4, `domains/data/migrations` |
|
||||
| Conflict silently resolved with no log (the policy is invisible) | P10 Edge Observability Survives Partition |
|
||||
| Divergent replica with no convergence-lag metric (invisible stale state) | P10, `domains/observability/metrics` |
|
||||
| Three-way merge with an unbounded conflict UI (user fatigue, no termination) | P4 (the merge must terminate) |
|
||||
| Operation-based CRDT without causal delivery (lost ops = divergence) | P4, C1 (the delivery contract is the bound) |
|
||||
| Merge that surfaces every concurrent conflict to the user (no default policy) | P4, C3 (the default policy is the simplicity bound) |
|
||||
| Sync with no convergence test (the merge is untested under partition) | P4, `domains/testing/pyramid` |
|
||||
@@ -0,0 +1,379 @@
|
||||
# Delivery Semantics — Derived Rules
|
||||
|
||||
> Derives from `domains/messaging/first-principles.md`. Applies P4
|
||||
> (Delivery Semantics are Explicit), P3 (Consumers are Idempotent),
|
||||
> P2 (Ordering is a Property, Not an Assumption), P5 (Dead-Letter
|
||||
> Handling is Defined), and P6 (Backpressure is Bounded) primarily,
|
||||
> with P10 (DLQ depth as an alert). For the dead-letter strategy
|
||||
> decision, see the comparison table below. Includes a fenced
|
||||
> idempotency-key dedup-store example (IDEATE-39). Cross-links
|
||||
> `domains/concurrency/patterns` for the in-process retry/backoff
|
||||
> analog, `domains/errors/patterns` for errors as data for message
|
||||
> failures, and `domains/observability/metrics` for DLQ depth as an
|
||||
> alert.
|
||||
|
||||
## The Three Delivery Semantics (P4 Delivery Semantics are Explicit)
|
||||
|
||||
- **At-most-once**: a message is delivered 0 or 1 times; loss is
|
||||
possible, duplication is not. The producer fires-and-forgets; the
|
||||
broker does not ack; the consumer does not dedup. Lowest latency,
|
||||
lowest implementation cost, lossy. Fits telemetry where a dropped
|
||||
sample is acceptable (MQTT QoS 0, fire-and-forget metrics).
|
||||
- **At-least-once**: a message is delivered 1 or more times;
|
||||
duplication is possible, loss is not. The producer sends and
|
||||
waits for a broker ack; the consumer processes and acks; a crash
|
||||
before the consumer's ack triggers redelivery. The consumer must
|
||||
be idempotent (P3). The default for side-effecting operations
|
||||
(orders, payments, commands). The vast majority of broker-backed
|
||||
queues (SQS standard, RabbitMQ ack, MQTT QoS 1).
|
||||
- **Exactly-once**: a message is delivered exactly 1 time; no
|
||||
loss, no duplication. In practice, this is at-least-once plus
|
||||
idempotency (P3), or a transactional consume-process-produce loop
|
||||
(P4 — see `domains/messaging/streams.md`). Jepsen analyses verify
|
||||
broker claims: "exactly-once" requires independent verification;
|
||||
the durable engineering practice is at-least-once with idempotent
|
||||
consumers (P3), which collapses to exactly-once under correct
|
||||
dedup.
|
||||
- The semantic is declared per channel (P4), not emergent. An
|
||||
unstated semantic is a defect: the consumer guesses, and the
|
||||
guess is wrong under the first failure. See
|
||||
`domains/messaging/queues.md` for the three-semantics comparison
|
||||
table (semantics, latency cost, implementation cost, when each
|
||||
fits).
|
||||
|
||||
## Idempotency (P3 Consumers are Idempotent)
|
||||
|
||||
- Idempotency is the correctness property that makes at-least-once
|
||||
safe. A consumer that processes the same message twice has the
|
||||
same effect as processing it once. The mechanism is the
|
||||
idempotency key: a per-message unique identifier the consumer
|
||||
uses to dedup redeliveries.
|
||||
- The idempotency key is per-message, not per-producer or
|
||||
per-session. A consumer that dedups by producer alone drops
|
||||
distinct messages issued in the same window. Use a UUID per
|
||||
message, or a deterministic key derived from the message content
|
||||
(e.g., `(entity, operation, version)`).
|
||||
- The dedup store is bounded (P6 — Backpressure is Bounded): a
|
||||
dedup store that grows without bound is a memory leak. Use a TTL
|
||||
window longer than the broker's max-redelivery window, or a
|
||||
bounded LRU. The TTL is the P6 bound: a key seen within the TTL
|
||||
is a redelivery; a key older than the TTL is expired (the broker
|
||||
has given up redelivering it).
|
||||
|
||||
## Idempotency-Key Dedup Store (IDEATE-39, P3 — fenced example)
|
||||
|
||||
The dedup store is the concrete mechanism that makes a consumer
|
||||
idempotent under at-least-once delivery. This is the fenced
|
||||
example required by IDEATE-39 (parallel to the v0.3 IDEATE-29
|
||||
signed-attestation fenced example): it is NOT prose-only — the
|
||||
consumer-with-dedup-store demonstrates P3 concretely.
|
||||
|
||||
```python
|
||||
# Idempotent consumer with a dedup store (P3 Consumers are
|
||||
# Idempotent, P6 Backpressure is Bounded — the dedup store is
|
||||
# TTL-bounded). The consumer dedups by idempotency key before
|
||||
# processing; a redelivered message is a no-op, not a double-apply.
|
||||
|
||||
import time
|
||||
|
||||
# P6: the dedup store is bounded by a TTL window. The TTL must
|
||||
# exceed the broker's max-redelivery window; beyond the TTL, the
|
||||
# key is expired (the broker has given up). A dedup store with no
|
||||
# TTL is a memory leak (P6 violation).
|
||||
DEDUP_TTL_SECONDS = 24 * 3600 # longer than max-redelivery window
|
||||
|
||||
class DedupStore:
|
||||
"""A TTL-bounded idempotency-key dedup store (P3, P6).
|
||||
|
||||
seen(key): True if the key was processed within the TTL.
|
||||
mark(key): Record the key as processed (with a timestamp).
|
||||
"""
|
||||
def __init__(self, backend):
|
||||
# backend is a Redis, a DB, or an in-process LRU. The
|
||||
# backend must be shared across consumer instances if the
|
||||
# subscription is shared (P2/P3 — see domains/messaging/
|
||||
# pubsub.md on shared vs independent subscriptions).
|
||||
self.backend = backend
|
||||
|
||||
def seen(self, key: str) -> bool:
|
||||
ts = self.backend.get(key)
|
||||
if ts is None:
|
||||
return False
|
||||
if time.time() - ts > DEDUP_TTL_SECONDS:
|
||||
# P6: expired. The broker has given up redelivering;
|
||||
# this key is no longer a redelivery signal.
|
||||
self.backend.delete(key)
|
||||
return False
|
||||
return True
|
||||
|
||||
def mark(self, key: str):
|
||||
self.backend.set(key, time.time(), ttl=DEDUP_TTL_SECONDS)
|
||||
|
||||
|
||||
# The idempotent consumer: dedup before process, mark after
|
||||
# process, ack after mark. A crash before mark re-processes (the
|
||||
# dedup store does not have the key); a crash before ack
|
||||
# redelivers (the broker did not see the ack) and the dedup store
|
||||
# makes the redelivery a no-op (P3).
|
||||
def consume_idempotent(broker, dedup: DedupStore, process):
|
||||
for message in broker.receive():
|
||||
# P3: dedup BEFORE process. A redelivered message is a
|
||||
# no-op, not a double-apply.
|
||||
if dedup.seen(message["idempotencyKey"]):
|
||||
broker.ack(message) # already processed; skip
|
||||
continue
|
||||
try:
|
||||
process(message["payload"])
|
||||
# P3: mark AFTER process succeeds. A crash between
|
||||
# process and mark re-processes (acceptable: the
|
||||
# process must be idempotent OR the mark must be
|
||||
# transactional with the process — see below).
|
||||
dedup.mark(message["idempotencyKey"])
|
||||
broker.ack(message)
|
||||
except TransientError as exc:
|
||||
# P6: bounded retry with backoff. Nack for redelivery;
|
||||
# the broker redelivers after exponential backoff.
|
||||
broker.nack(message, delay=backoff(message["attempt"]))
|
||||
except PoisonError as exc:
|
||||
# P5: poison message. Route to DLQ, do NOT retry
|
||||
# forever (see the DLQ routing rule below).
|
||||
route_to_dlq(broker, message, exc)
|
||||
broker.ack(message) # remove from the origin queue
|
||||
```
|
||||
|
||||
- The order `process → mark → ack` gives at-least-once with
|
||||
idempotent dedup: a crash before `mark` re-processes (the dedup
|
||||
store does not have the key), and a crash before `ack`
|
||||
redelivers (the dedup store makes the redelivery a no-op). If
|
||||
`process` is not itself idempotent, the `process → mark` window
|
||||
must be transactional (e.g., process and mark in one DB
|
||||
transaction) — otherwise a crash in the window double-applies.
|
||||
- For a non-idempotent `process` (e.g., a payment that must not
|
||||
double-charge), use a transactional dedup: process and mark in
|
||||
one DB transaction, so the mark commits iff the process
|
||||
commits. This is the "exactly-once via idempotency" pattern
|
||||
(P4): at-least-once delivery plus a transactional
|
||||
process-and-mark collapses to exactly-once under correct
|
||||
transactional semantics.
|
||||
|
||||
## Ordering (P2 Ordering is a Property, Not an Assumption)
|
||||
|
||||
- The delivery semantic interacts with ordering (P2). At-least-once
|
||||
with per-partition ordering: a redelivery within a partition
|
||||
preserves order (the redelivered message re-appears in its
|
||||
original position relative to other messages the consumer has
|
||||
not yet seen). At-least-once with no ordering: a redelivery may
|
||||
appear out of order relative to messages delivered after it.
|
||||
- The consumer must not assume an ordering property the broker
|
||||
does not provide (P2). A standard queue delivers per-receive-node
|
||||
arrival order but no global order and no order across redeliveries;
|
||||
a FIFO queue delivers strict per-group order including across
|
||||
redeliveries; a partitioned stream delivers strict per-partition
|
||||
order across redeliveries. Document the property; do not assume
|
||||
it. See `domains/messaging/queues.md` (FIFO vs standard) and
|
||||
`domains/messaging/streams.md` (per-partition order).
|
||||
|
||||
## Dead-Letter Strategies (P5 Dead-Letter Handling is Defined)
|
||||
|
||||
- A poison message (unparseable, repeatedly failing, or exhausting
|
||||
the retry budget) must be routed to a dead-letter queue, not
|
||||
retried forever or silently dropped (P5). The DLQ is observable
|
||||
(P10 — depth is an alert) and drainable (an operator can
|
||||
inspect, replay, or discard with audit).
|
||||
- The dead-letter strategy determines when a message is
|
||||
dead-lettered and what the operator sees. The choice is the
|
||||
decision matrix below (D-069).
|
||||
|
||||
## Dead-Letter Strategy Comparison (D-069)
|
||||
|
||||
| Strategy | When It Applies | Failure Visibility | Operational Cost |
|
||||
|----------|-----------------|-------------------|------------------|
|
||||
| **Retry-count-limit** | A fixed max-redeliveries count (e.g., 5). After N redeliveries, route to DLQ. Simple, predictable. | The redelivery count is visible in the DLQ entry; the operator sees how many times it was retried. | Low — a counter per message; no backoff tuning. Risk: retries fire as fast as the broker redelivers, hammering a downstream that is already failing (P6 — no backoff = no backpressure escape). |
|
||||
| **TTL-with-backoff** | A max time-to-live for redelivery (e.g., 30 minutes) with exponential backoff between retries. After the TTL, route to DLQ. | The TTL and the backoff schedule are visible; the operator sees the retry timeline. | Medium — backoff tuning per message type. Benefit: backoff gives the downstream time to recover (P6 — the retry rate is bounded); fits transient failures (a downstream that is briefly unavailable). |
|
||||
| **Poison-queue** | A separate queue for messages that fail a specific check (unparseable, schema-invalid, unknown type) before any processing retry. Routed immediately, not retried. | The poison queue is a separate signal from the DLQ; the operator sees parse-vs-process failures distinctly. | Low — a routing rule per check. Benefit: distinguishes "never going to succeed" (poison) from "might succeed on retry" (DLQ). Use for unparseable messages that no retry will fix. |
|
||||
| **DLQ + alert** | Any of the above strategies, plus an alert on DLQ depth. The DLQ is observable (P10) — depth, age, and rate are alerted. | Highest — the operator is paged on DLQ growth; the DLQ is a first-class signal, not a graveyard. | Medium — alerting setup per DLQ. This is the P5/P10 floor: a DLQ without an alert is a silent correctness defect (poison messages accumulate invisibly). |
|
||||
|
||||
- The default for transient failures is **TTL-with-backoff + DLQ
|
||||
+ alert**: backoff gives the downstream time to recover (P6),
|
||||
the TTL bounds the retry budget (P5), the DLQ captures the
|
||||
unprocessable, and the alert makes it visible (P10). The default
|
||||
for unparseable messages is **poison-queue + alert**: route
|
||||
immediately, do not retry a message no retry will fix.
|
||||
- **Retry-count-limit alone** (no backoff, no alert) is the
|
||||
`messaging-unbounded-retry` chaos anti-pattern's cousin: it
|
||||
caps the count but hammers the downstream at full retry rate,
|
||||
and the DLQ grows silently if no alert is wired. Always pair
|
||||
a retry budget with backoff (P6) and an alert (P10).
|
||||
- The failure-visibility column is the P10 check: every strategy
|
||||
must surface the failure to the operator. A strategy with no
|
||||
visibility is a P10 violation regardless of its retry semantics.
|
||||
- The operational-cost column is the C8 tradeoff: more visibility
|
||||
and more backoff cost more to set up but pay back in operational
|
||||
stability. The P5/P10 floor is "DLQ + alert"; below that, the
|
||||
strategy is a silent defect waiting to grow.
|
||||
|
||||
## DLQ Routing Rule (P5, P10)
|
||||
|
||||
```python
|
||||
# DLQ routing rule (P5 dead-letter handling, P6 bounded retry with
|
||||
# backoff, P10 DLQ depth alert). Combines TTL-with-backoff for
|
||||
# transient failures and poison-queue for unparseable messages,
|
||||
# with an alert on DLQ depth.
|
||||
|
||||
import json, time
|
||||
|
||||
MAX_RETRY_TTL_SECONDS = 30 * 60 # 30 min total retry window
|
||||
DLQ = "orders-dlq"
|
||||
POISON = "orders-poison" # unparseable; never retried
|
||||
|
||||
def route_to_dlq(broker, message, reason):
|
||||
"""Route a message to the DLQ with audit metadata (P5)."""
|
||||
broker.send(DLQ, body=json.dumps({
|
||||
"original": message,
|
||||
"reason": str(reason),
|
||||
"deadLetteredAt": now_iso(),
|
||||
"redeliveryCount": message.get("attempt", 0),
|
||||
}))
|
||||
# P10: emit a metric so DLQ depth alerts fire. A DLQ that
|
||||
# grows with no alert is a silent correctness defect (P5/P10).
|
||||
metrics.increment("dlq.depth", tags={"queue": "orders"})
|
||||
# The ack removes the message from the origin queue; the DLQ
|
||||
# is the durable record (P5 — observable and drainable).
|
||||
|
||||
def consume_with_dlq(broker, dedup, process):
|
||||
for message in broker.receive():
|
||||
# P1: parse first. An unparseable message is poison —
|
||||
# route immediately, do NOT retry (no retry will fix it).
|
||||
try:
|
||||
payload = json.loads(message["body"])
|
||||
except (ValueError, SchemaError) as exc:
|
||||
broker.send(POISON, body=json.dumps({
|
||||
"original": message["body"],
|
||||
"reason": f"parse-failed: {exc}",
|
||||
"poisonedAt": now_iso(),
|
||||
}))
|
||||
metrics.increment("poison.depth", tags={"queue": "orders"})
|
||||
broker.ack(message) # remove from origin; poison queue holds it
|
||||
continue
|
||||
|
||||
# P3: dedup before process.
|
||||
if dedup.seen(payload["idempotencyKey"]):
|
||||
broker.ack(message); continue
|
||||
|
||||
attempt = payload.get("attempt", 0)
|
||||
first_attempt_ts = payload.get("firstAttemptTs", time.time())
|
||||
|
||||
try:
|
||||
process(payload)
|
||||
dedup.mark(payload["idempotencyKey"])
|
||||
broker.ack(message)
|
||||
except TransientError as exc:
|
||||
# P6: TTL-with-backoff. If the retry window is
|
||||
# exhausted, route to DLQ; otherwise redeliver with
|
||||
# exponential backoff.
|
||||
if time.time() - first_attempt_ts > MAX_RETRY_TTL_SECONDS:
|
||||
route_to_dlq(broker, message, exc) # P5
|
||||
broker.ack(message)
|
||||
else:
|
||||
broker.nack(message, delay=backoff(attempt))
|
||||
except PermanentError as exc:
|
||||
# A permanent error (e.g., a not-found dependency)
|
||||
# does not benefit from retry — route to DLQ now.
|
||||
route_to_dlq(broker, message, exc)
|
||||
broker.ack(message)
|
||||
```
|
||||
|
||||
- The routing rule distinguishes three failure modes: **poison**
|
||||
(unparseable — route immediately, no retry), **transient**
|
||||
(retry with backoff until the TTL, then DLQ), and **permanent**
|
||||
(a retry will not fix it — DLQ now). This distinction is the P5
|
||||
discipline: not every failure is a retry; some are immediate
|
||||
DLQs.
|
||||
- The DLQ entry carries `reason`, `deadLetteredAt`, and
|
||||
`redeliveryCount` — it is auditable (the operator knows why each
|
||||
message was dead-lettered and how many times it was retried).
|
||||
This is the errors-as-data discipline — see
|
||||
`domains/errors/patterns` for the general principle a DLQ entry
|
||||
instantiates.
|
||||
|
||||
## Retry Budgets and Backoff (P6 Backpressure is Bounded)
|
||||
|
||||
- The retry budget is the cap on redelivery: a count, a TTL, or
|
||||
both. After the budget, the message routes to the DLQ (P5). An
|
||||
unbounded retry budget is the `messaging-unbounded-retry` chaos
|
||||
anti-pattern (P5 breach): the consumer never makes progress past
|
||||
the poison message.
|
||||
- **Exponential backoff** spaces retries: 1s, 2s, 4s, 8s, ... with
|
||||
a jitter to avoid thundering-herd synchrony. Backoff gives the
|
||||
downstream time to recover (P6 — the retry rate is bounded,
|
||||
giving the downstream a chance to catch up). A retry with no
|
||||
backoff hammers the downstream at full rate, making the failure
|
||||
worse.
|
||||
- The retry budget × the backoff schedule is the P6 bound: the
|
||||
consumer's retry load is bounded by design, not by luck. See
|
||||
`domains/concurrency/patterns` Pattern 6 (Timeout on Every
|
||||
Block) for the in-process retry/backoff analog; messaging owns
|
||||
the broker-backed instance where the redelivery comes from the
|
||||
broker across a network, not an in-process loop (D-062).
|
||||
|
||||
## Poison Messages (P5, P1)
|
||||
|
||||
- A poison message is one no retry will fix: unparseable (the
|
||||
schema is wrong, P1), unknown type (the consumer does not handle
|
||||
this version, P9), or a permanent failure (a not-found
|
||||
dependency). Retrying a poison message wastes resources and
|
||||
blocks the queue (P6 — the consumer never makes progress).
|
||||
- Poison messages route to the poison queue immediately (no
|
||||
retry), distinct from the DLQ (which holds messages that
|
||||
exhausted their retry budget on transient failures). The
|
||||
distinction is the P5 discipline: a poison queue is for
|
||||
"never going to succeed"; a DLQ is for "might have succeeded
|
||||
but didn't within the budget."
|
||||
- A poison queue without an alert is the same defect as a DLQ
|
||||
without an alert (P10): the operator cannot see the poison
|
||||
accumulating. Wire both to `domains/observability/metrics`.
|
||||
|
||||
## Cross-Link to Concurrency (P6, cross-link concurrency/patterns)
|
||||
|
||||
- The retry/backoff discipline here is the cross-process analog
|
||||
of `domains/concurrency/patterns` Pattern 6 (Timeout on Every
|
||||
Block) for in-process retry. Concurrency owns the in-process
|
||||
analog (a retry loop within one program, with a timeout per
|
||||
attempt); messaging owns the broker-backed instance (the broker
|
||||
redelivers across a network, the consumer applies backoff via
|
||||
nack-with-delay). The failure model differs: in-process retry
|
||||
fails by a thread crash or a timeout; broker-backed retry fails
|
||||
by network partition, broker restart, or consumer crash-and-retry
|
||||
(D-062).
|
||||
- The cross-link is one-directional outward (messaging →
|
||||
concurrency) per D-026 extended: messaging references concurrency
|
||||
as the in-process foundation; concurrency does not back-link to
|
||||
messaging.
|
||||
|
||||
## Cross-Link to Errors (P5, cross-link errors/patterns)
|
||||
|
||||
- A poison message is an errors-as-data instance: the failure is
|
||||
captured as a DLQ entry (with `reason`, `redeliveryCount`,
|
||||
`deadLetteredAt`), not swallowed. See `domains/errors/patterns`
|
||||
for the general errors-as-data discipline a DLQ entry
|
||||
instantiates. The DLQ is the async-messaging instance of an
|
||||
error log — observable, auditable, drainable.
|
||||
- The cross-link is one-directional outward (messaging → errors):
|
||||
messaging references errors for the errors-as-data pattern;
|
||||
errors does not back-link to messaging.
|
||||
|
||||
## What Violates Delivery-Semantics Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| Unstated delivery semantic (at-least-once vs exactly-once guessed) | P4 Delivery Semantics are Explicit |
|
||||
| Non-idempotent consumer under at-least-once delivery | P3 Consumers are Idempotent |
|
||||
| Dedup store with no TTL (memory leak; grows without bound) | P6 Backpressure is Bounded |
|
||||
| Dedup key per-producer (distinct messages in the same window deduped) | P3 Consumers are Idempotent |
|
||||
| Retry with no backoff (hammers the downstream at full rate) | P6 Backpressure is Bounded |
|
||||
| Unbounded retry budget (consumer never progresses past the poison) | P5 Dead-Letter Handling is Defined |
|
||||
| DLQ with no depth alert (poison messages accumulate invisibly) | P10, `domains/observability/metrics` |
|
||||
| Poison message retried forever (no poison queue, no immediate DLQ) | P5 Dead-Letter Handling is Defined |
|
||||
| Process-not-idempotent with non-transactional mark (crash in window double-applies) | P3, P4 |
|
||||
| DLQ entry with no reason/audit metadata (uninspectable failure) | P5, `domains/errors/patterns` |
|
||||
| Ordering assumption the broker does not provide (FIFO assumed on standard queue) | P2 Ordering is a Property, Not an Assumption |
|
||||
@@ -0,0 +1,307 @@
|
||||
# Messaging — First Principles
|
||||
|
||||
## 1. The Principles
|
||||
|
||||
### P1. Messages are Contracts
|
||||
A message has an explicit, versioned schema. Producer and consumer
|
||||
agree on shape before exchange; the schema is the boundary, not a
|
||||
guess. A schemaless message — a free-form JSON blob the consumer
|
||||
parses by hope — is a defect: the consumer breaks silently on the
|
||||
next shape change, and the producer has no contract to evolve
|
||||
against. This derives from `C1 Correctness` (the exchange must
|
||||
carry what the parties agreed to) and `C2 Clarity` (the schema
|
||||
makes the boundary obvious to both sides). This is the
|
||||
cross-process expression of the contract discipline that
|
||||
`domains/api/rest` owns for synchronous request/response: the
|
||||
message schema is to async exchange what the API contract is to
|
||||
sync exchange. It is distinct from
|
||||
`domains/concurrency/patterns` Pattern 1 (Message Passing), which
|
||||
owns the *in-process* channel primitive — here the contract spans
|
||||
separate systems and survives network failure (D-062). See
|
||||
`domains/messaging/queues.md` for the queue-flavored application
|
||||
and `domains/messaging/streams.md` for the durable-log-flavored
|
||||
application.
|
||||
|
||||
### P2. Ordering is a Property, Not an Assumption
|
||||
Ordering guarantees — per-partition strict, global, or none — are
|
||||
explicit and documented. "It's FIFO" is a claim that must be backed
|
||||
by the broker's partitioning contract, not an assumption the
|
||||
consumer makes and the broker may not honor. A standard queue
|
||||
delivers in arrival order per receive-node but offers no global
|
||||
ordering across shards; a FIFO queue delivers strict per-message-
|
||||
group order but at a latency cost; a partitioned stream delivers
|
||||
strict per-partition order but only within a partition. Each is a
|
||||
distinct, declared property. This derives from `C1 Correctness`
|
||||
(order is a correctness property — a consumer that assumes order
|
||||
the broker does not provide is wrong) and `C2 Clarity` (the
|
||||
ordering guarantee is documented, not discovered in production).
|
||||
This is distinct from in-process ordering, which
|
||||
`domains/concurrency/patterns` Pattern 1 owns for channels within
|
||||
one program: messaging ordering survives network failure, broker
|
||||
restart, and consumer crash-and-retry — a stronger failure model
|
||||
than thread-local channels (D-062). The `messaging-shared-
|
||||
subscription` chaos anti-pattern breaches this rule: two consumers
|
||||
sharing one subscription break per-consumer ordering because the
|
||||
broker dispatches each message to an arbitrary consumer. See
|
||||
`domains/messaging/streams.md` for the partition-order contract
|
||||
and `domains/messaging/delivery-semantics.md` for the interaction
|
||||
of ordering with the three delivery semantics.
|
||||
|
||||
### P3. Consumers are Idempotent
|
||||
Delivery is at-least-once by default across the network; a
|
||||
consumer deduplicates via idempotency keys or deterministic
|
||||
processing. "Exactly-once" is idempotency plus at-least-once, not a
|
||||
broker guarantee — Jepsen analyses of Kafka, RabbitMQ, and NATS
|
||||
establish that exactly-once claims require independent
|
||||
verification, and the durable engineering practice is to make
|
||||
consumers idempotent under redelivery. A non-idempotent consumer
|
||||
under at-least-once delivery doubles the effect on every retry; a
|
||||
non-idempotent consumer under a claimed exactly-once broker is a
|
||||
bug waiting for the broker's exactly-once invariant to break. This
|
||||
derives from `C1 Correctness`: correctness under redelivery is the
|
||||
contract, not a nice-to-have. This parallels
|
||||
`domains/edge/P5 Edge Operations are Idempotent` (the
|
||||
cross-partition device-and-cache-flavored analog) and is the
|
||||
cross-process instance of the retry-safety discipline that
|
||||
`domains/concurrency/patterns` Pattern 6 (Timeout on Every Block)
|
||||
implies for in-process retry. It is distinct from in-process
|
||||
retry because the redelivery comes from the broker across a
|
||||
network, not from an in-process loop (D-062). See
|
||||
`domains/messaging/delivery-semantics.md` for the idempotency-key
|
||||
dedup-store pattern.
|
||||
|
||||
### P4. Delivery Semantics are Explicit
|
||||
At-least-once / at-most-once / exactly-once is a declared choice
|
||||
per channel, not an emergent behavior. The tradeoff — latency cost,
|
||||
implementation complexity, operational cost — is made consciously
|
||||
and documented. At-most-once is fire-and-forget (low latency, lossy);
|
||||
at-least-once is acked with possible duplication (the default,
|
||||
requires idempotent consumers per P3); exactly-once is at-least-once
|
||||
plus idempotency or a transactional two-phase commit (highest cost,
|
||||
narrowest fit). An unstated semantic is a defect: the consumer
|
||||
guesses, and the guess is wrong under the first failure. This
|
||||
derives from `C1 Correctness` (the chosen semantic must hold) and
|
||||
`C2 Clarity` (the tradeoff is visible to the reader and the
|
||||
operator). This is the cross-process analog of the explicit-failure-
|
||||
mode discipline that `domains/errors/patterns` owns for synchronous
|
||||
code — messaging makes the delivery-mode choice as explicit as an
|
||||
error-handling choice. See `domains/messaging/queues.md` for the
|
||||
three-semantics comparison table and `domains/messaging/delivery-
|
||||
semantics.md` for the correctness properties of each.
|
||||
|
||||
### P5. Dead-Letter Handling is Defined
|
||||
Poison messages — unparseable, repeatedly failing, or exhausting
|
||||
the retry budget — are routed to a dead-letter queue, not retried
|
||||
forever or silently dropped. The DLQ is observable and drainable: an
|
||||
operator can inspect it, replay from it, or discard with audit. An
|
||||
unbounded retry loop is a livelock: the consumer never makes
|
||||
progress past the poison message. A silent drop is a correctness
|
||||
defect: the message vanished with no record. This derives from `C1
|
||||
Correctness` (poison messages must not livelock the consumer or
|
||||
silently disappear) and `C5 Reversibility` (the DLQ is the
|
||||
reversibility mechanism — a dead-lettered message can be reprocessed
|
||||
after the bug is fixed). This is the cross-process analog of the
|
||||
bounded-error discipline that `domains/errors/patterns` owns for
|
||||
synchronous code: a poison message is an error-as-data instance
|
||||
that must be observable and recoverable, not swallowed. It is
|
||||
distinct from in-process error handling because the failure spans a
|
||||
network and a consumer restart (D-062). See
|
||||
`domains/messaging/delivery-semantics.md` for the dead-letter
|
||||
strategy comparison table and the DLQ routing rule pattern.
|
||||
|
||||
### P6. Backpressure is Bounded
|
||||
A slow consumer cannot unbounded-buffer the broker or the
|
||||
producer. Backpressure is explicit: consumer lag is visible,
|
||||
max-unacked is bounded, the retry budget is capped. A consumer
|
||||
that falls behind without a visible signal is a silent backlog —
|
||||
the operator cannot fix what they cannot see, and the broker's
|
||||
memory grows without bound until it fails. This derives from `C1
|
||||
Correctness` (a backlog that grows until OOM is a correctness
|
||||
failure) and `C8 Economy` (the broker's memory is bounded by
|
||||
design, not by luck). This is distinct from
|
||||
`domains/concurrency/P9 Bounded Queues` and
|
||||
`domains/concurrency/patterns` Pattern 5 (Bounded Queue with
|
||||
Backpressure), which own the *in-process* analog: concurrency's
|
||||
bounded queue fails by OOM or thread crash; messaging's bounded
|
||||
backpressure fails by network partition, broker restart, or
|
||||
consumer crash-and-retry (D-062). The Reactive Streams
|
||||
specification (`request(n)`, `onNext` bounded) is the in-process
|
||||
instance; messaging's broker-backed backpressure is the
|
||||
cross-process instance above it. See `domains/messaging/queues.md`
|
||||
for prefetch and max-unacked and `domains/observability/metrics`
|
||||
for consumer-lag as an alert.
|
||||
|
||||
### P7. Partitioning is Intentional
|
||||
The partition key determines ordering, parallelism, and hotspots.
|
||||
Key choice is a design decision with documented rationale, not a
|
||||
default. A key that hashes unevenly creates a hot partition that
|
||||
limits throughput; a key that does not match the ordering need
|
||||
breaks per-key semantics; a key that is too coarse (one partition
|
||||
for the whole topic) serializes all the traffic. The partition
|
||||
count is a capacity bound: too few partitions cap parallelism, too
|
||||
many partition overhead the broker. This derives from `C4 Locality`
|
||||
(ordering and parallelism are co-located with the partition) and
|
||||
`C6 Composability` (the partition is the unit of parallelism and
|
||||
scaling — consumer groups compose from per-partition workers).
|
||||
This is the cross-process analog of the locality discipline that
|
||||
`domains/performance/` owns for generic data-near-compute
|
||||
optimization: performance's locality is algorithmic (data near
|
||||
compute); messaging's locality is partitional (order and
|
||||
parallelism near the partition). See `domains/messaging/streams.md`
|
||||
for the partitioned-log model and consumer-group rebalance
|
||||
strategies.
|
||||
|
||||
### P8. Replay and Retention are Configured
|
||||
Retention windows and replay-from-offset are explicit. A message
|
||||
is not ephemeral by default; the broker is a durable log, not a
|
||||
pipe. A topic with no retention is a fire-and-forget stream — a
|
||||
consumer that falls behind loses data permanently; a topic with
|
||||
infinite retention is an unbounded log — the broker grows until
|
||||
disk exhaustion. Both are defects: the retention window is a
|
||||
declared bound, and replay-from-offset is the mechanism that makes
|
||||
the log durable (re-consumable) rather than ephemeral. This derives
|
||||
from `C5 Reversibility` (a retained message is reversible — it can
|
||||
be re-consumed; an ephemeral message is not) and `C7 Observability`
|
||||
(the durable log is itself an observable record of what happened —
|
||||
the offset is the position from which to replay). This is the
|
||||
foundation for `domains/messaging/streams.md` and the rule that
|
||||
distinguishes a stream from a queue (a queue deletes on ack; a
|
||||
stream retains for replay). See `domains/messaging/pubsub.md` for
|
||||
the pub/sub-vs-stream durability boundary.
|
||||
|
||||
### P9. Schemas Evolve Compatibly
|
||||
Schema changes are backward- and forward-compatible by
|
||||
construction. Breaking changes are versioned migrations, not
|
||||
silent shape edits. A producer that ships a new field the old
|
||||
consumer ignores is backward-compatible; a consumer that handles a
|
||||
missing field the new producer omits is forward-compatible. A
|
||||
silent schema change — the producer renames a field and the
|
||||
consumer parses `undefined` — is a P1 violation (the contract was
|
||||
broken) compounded here as an evolution defect. This derives from
|
||||
`C5 Reversibility` (a schema change is reversible by versioning —
|
||||
the old shape is still readable) and `C6 Composability` (producers
|
||||
and consumers of different versions compose because the schema
|
||||
evolves compatibly). This parallels `domains/data/migrations`
|
||||
(schema migration for databases) and `domains/api/versioning`
|
||||
(API contract evolution): messaging's schema evolution is the
|
||||
async instance of the same compatibility discipline. See
|
||||
`domains/messaging/streams.md` for the stream-schema-evolution
|
||||
angle.
|
||||
|
||||
### P10. Messaging is Observable
|
||||
Consumer lag, DLQ depth, throughput, and consumer-group health are
|
||||
first-class signals. Silent backlog is a bug, not a feature: a
|
||||
consumer that falls behind with no lag metric is invisible until
|
||||
the downstream effect surfaces — by which time the backlog may be
|
||||
hours or days. A DLQ that grows without an alert is a silent
|
||||
correctness defect: poison messages are accumulating and no one
|
||||
knows. This derives from `C7 Observability` (the broker's behavior
|
||||
is visible to the operator) and `C1 Correctness` (backlog
|
||||
detection is a correctness bound — unbounded lag is a failure).
|
||||
This is distinct from `domains/observability/metrics`, which owns
|
||||
*generic* structured metrics; messaging owns the *broker-specific*
|
||||
signals — lag, DLQ depth, partition imbalance, consumer-group
|
||||
rebalance events. See `domains/observability/metrics` for the
|
||||
generic SLI/SLO discipline and `domains/observability/tracing` for
|
||||
cross-partition traces.
|
||||
|
||||
## 2. Core Principle Trace
|
||||
|
||||
Each messaging P-rule derives from one or more core C-rules
|
||||
(C1–C8). The matrix extension lands in P4 of the v0.4 plan; the
|
||||
traces below are authoritative. Messaging is a broad-derivation
|
||||
domain touching 7 of 8 core principles (C1, C2, C4, C5, C6, C7,
|
||||
C8); C3 (Simplicity) is not a primary derivation — messaging is
|
||||
inherently a tradeoff domain where simplicity yields to the
|
||||
correctness of delivery guarantees (a simpler-than-necessary
|
||||
delivery model does not handle the failure cases, per C3's
|
||||
"simpler than necessary is also a violation").
|
||||
|
||||
| P-rule | Core | Why |
|
||||
|--------|------|-----|
|
||||
| P1 Messages are Contracts | C1, C2 | Correctness of the exchange; clarity of the schema boundary |
|
||||
| P2 Ordering is a Property, Not an Assumption | C1, C2 | Correctness of order; clarity of the guarantee |
|
||||
| P3 Consumers are Idempotent | C1 | Correctness under redelivery |
|
||||
| P4 Delivery Semantics are Explicit | C1, C2 | Correctness of the chosen semantic; clarity of the tradeoff |
|
||||
| P5 Dead-Letter Handling is Defined | C1, C5 | Correctness of poison-message routing; reversibility of reprocessing |
|
||||
| P6 Backpressure is Bounded | C1, C8 | Correctness of bounded backlog; economy of broker memory |
|
||||
| P7 Partitioning is Intentional | C4, C6 | Locality of order; composability of parallelism |
|
||||
| P8 Replay and Retention are Configured | C5, C7 | Reversibility of replay; observability of the durable log |
|
||||
| P9 Schemas Evolve Compatibly | C5, C6 | Reversibility of schema changes; composability of versions |
|
||||
| P10 Messaging is Observable | C7, C1 | Observability of lag/DLQ; correctness of backlog detection |
|
||||
|
||||
## 3. What Violates These Principles
|
||||
|
||||
| Violation | Principle Breached |
|
||||
|-----------|-------------------|
|
||||
| Schemaless message (no versioned contract; consumer parses by guess) | P1 Messages are Contracts |
|
||||
| "It's FIFO" with no documented partition contract | P2 Ordering is a Property, Not an Assumption |
|
||||
| Non-idempotent consumer under at-least-once delivery | P3 Consumers are Idempotent |
|
||||
| Unstated delivery semantic (at-least-once vs exactly-once guessed) | P4 Delivery Semantics are Explicit |
|
||||
| No dead-letter queue (poison message retried forever or silently dropped) | P5 Dead-Letter Handling is Defined |
|
||||
| Unbounded retry budget (no cap; slow consumer stalls the partition) | P6 Backpressure is Bounded |
|
||||
| Default partition key (no rationale; hotspot or wrong-order) | P7 Partitioning is Intentional |
|
||||
| Ephemeral broker (no retention; no replay) | P8 Replay and Retention are Configured |
|
||||
| Silent schema change (producer breaks consumers with no version bump) | P9 Schemas Evolve Compatibly |
|
||||
| Silent backlog (no lag metric; consumer falls behind invisibly) | P10 Messaging is Observable |
|
||||
| Shared subscription (two consumers share one subscription; per-consumer ordering breaks) | P2 Ordering is a Property, Not an Assumption (P3 compounding) |
|
||||
| Blocking consumer (slow downstream call with no timeout; broker redelivers to the stuck consumer) | P6 Backpressure is Bounded |
|
||||
|
||||
## 4. Relationship to Other Domains
|
||||
|
||||
Messaging systems are the engineering discipline of
|
||||
**cross-process, cross-system asynchronous communication via
|
||||
brokers**. Producer and consumer are separate systems; the broker
|
||||
is the intermediary that brokers delivery, ordering, retention,
|
||||
and failure semantics. The distinguishing constraints are a
|
||||
cross-process failure model (network, not crash), explicit
|
||||
delivery semantics, decoupled producer/consumer lifecycle, and
|
||||
replay-and-retention as a durable-log property. Messaging overlaps
|
||||
`domains/concurrency/` by *subject* (messages, queues,
|
||||
backpressure) but not by *failure model*: per D-062, messaging
|
||||
owns the cross-process/network-failure-model angle; concurrency
|
||||
owns the in-process/crash-failure-model angle. The discriminator
|
||||
is the failure model: concurrency's queue fails by OOM or thread
|
||||
crash; messaging's queue fails by network partition, broker
|
||||
restart, or consumer crash-and-retry. Messaging extends
|
||||
concurrency's bounded-queue/backpressure model to the network-
|
||||
partition regime. Cross-links are one-directional outward (per
|
||||
D-026 extended); no back-link edits to v0.1/v0.2/v0.3 content.
|
||||
|
||||
- `domains/concurrency/patterns` ← P6 (the broker-backed bounded
|
||||
queue is the cross-process analog of the in-process bounded
|
||||
buffer — concurrency Pattern 5 owns in-process; messaging owns
|
||||
the network-failure-model instance above it, per D-062)
|
||||
- `domains/concurrency/patterns` ← P3 (idempotent retry is the
|
||||
cross-process analog of in-process retry-safety — the failure
|
||||
model differs: broker redelivery across a network vs in-process
|
||||
loop)
|
||||
- `domains/observability/metrics` ← P10 (consumer lag and DLQ
|
||||
depth as alerts; observability owns the generic SLI/SLO
|
||||
discipline, messaging owns the broker-specific signals)
|
||||
- `domains/observability/tracing` ← P10 (cross-partition traces
|
||||
for stream processing; observability owns the generic tracing
|
||||
discipline, messaging owns the cross-partition propagation)
|
||||
- `domains/data/schema-design` ← P1, P9 (message schema design
|
||||
and evolution; data owns the generic schema discipline,
|
||||
messaging owns the cross-process message-shape instance)
|
||||
- `domains/errors/patterns` ← P5 (errors as data for message
|
||||
failures; a poison message is an error-as-data instance that must
|
||||
be observable and recoverable, not swallowed)
|
||||
- `domains/edge/iot` ← P4 (the edge↔messaging cross-link
|
||||
resolves bidirectionally here: edge/iot.md links outward to
|
||||
messaging/queues for MQTT QoS parallels to delivery semantics;
|
||||
this first-principles doc acknowledges the back-link — the
|
||||
edge/iot.md → messaging/queues link from P1 now resolves because
|
||||
messaging/queues.md exists, completing the bidirectionality per
|
||||
IDEATE-40)
|
||||
|
||||
> Note: the edge/iot.md → messaging/queues cross-link (MQTT QoS
|
||||
> parallels for delivery semantics) was authored in P1 with a
|
||||
> dangling reference; this P2 authorship of messaging/queues.md
|
||||
> resolves it. The bidirectionality is verified in P5
|
||||
> (ATELIER-114 per IDEATE-40). The cross-link is one-directional
|
||||
> outward from edge/iot.md; this first-principles doc
|
||||
> acknowledges the resolution without editing edge/iot.md (per
|
||||
> D-026 extended — no back-link edits to v0.1/v0.2/v0.3 or to
|
||||
> P1-authored edge content).
|
||||
@@ -0,0 +1,269 @@
|
||||
# Pub/Sub — Derived Rules
|
||||
|
||||
> Derives from `domains/messaging/first-principles.md`. Applies P1
|
||||
> (Messages are Contracts), P2 (Ordering is a Property, Not an
|
||||
> Assumption), P3 (Consumers are Idempotent), and P4 (Delivery
|
||||
> Semantics are Explicit) primarily, with P7 (partitioning), P10
|
||||
> (per-subscription lag). The `messaging-shared-subscription` chaos
|
||||
> anti-pattern lives here (pre-specified in P4 ATELIER-110).
|
||||
> Cross-links `domains/messaging/streams` for the pub/sub-vs-stream
|
||||
> durability boundary and `domains/observability/metrics` for
|
||||
> per-subscription lag.
|
||||
|
||||
## What Pub/Sub Is (P1 Messages are Contracts)
|
||||
|
||||
- Pub/sub is the fan-out primitive: a producer publishes a message
|
||||
to a topic; N independent subscriptions each receive a copy. The
|
||||
message has an explicit, versioned schema (P1): the topic's
|
||||
schema is the contract every subscription agrees to before
|
||||
subscribing. A schemaless topic is a defect — every subscriber
|
||||
breaks silently on the next shape change.
|
||||
- The boundary with queues is the fan-out ratio. A queue is
|
||||
point-to-point (one producer, one consumer); pub/sub is
|
||||
one-to-many (one producer, N consumers, each with its own
|
||||
subscription). The boundary with streams is the durability model
|
||||
— see the cross-link below. Pub/sub is an async concern because
|
||||
producer and consumers are separate systems and the failure model
|
||||
is network, not crash (D-062).
|
||||
- See `domains/messaging/queues.md` for the point-to-point variant
|
||||
and `domains/messaging/streams.md` for the durable-log variant.
|
||||
|
||||
## Topic / Subscription Model (P1, P3, P4)
|
||||
|
||||
- A **topic** is the named stream of messages. A **subscription**
|
||||
is a durable cursor over the topic: each subscription receives
|
||||
every message published after it was created (subject to
|
||||
retention and filtering). The subscription is independent — its
|
||||
ack, redelivery, and DLQ are per-subscription, not shared.
|
||||
- Each subscription is a consumer under at-least-once by default
|
||||
(P4): the broker redelivers until the subscription acks, and the
|
||||
subscriber must be idempotent (P3). A subscription with no
|
||||
idempotency dedup duplicates every redelivered message.
|
||||
- The topic's schema evolves compatibly (P9 — Schemas Evolve
|
||||
Compatibly): a new field the old subscriber ignores is
|
||||
backward-compatible; a renamed field the old subscriber parses
|
||||
as `undefined` is a P1 violation.
|
||||
|
||||
```python
|
||||
# Publish + two independent subscriptions (P1 contract, P3
|
||||
# idempotency, P4 at-least-once per subscription). Each
|
||||
# subscription is an independent durable cursor; acking one does
|
||||
# not affect the other.
|
||||
|
||||
import json, uuid
|
||||
|
||||
# --- Publisher ---
|
||||
def publish(topic, event, broker):
|
||||
# P1: versioned schema on the topic. All subscribers must
|
||||
# understand this schema (or a compatible superset — P9).
|
||||
message = {
|
||||
"schema": "user.signed-up.v1",
|
||||
"id": str(uuid.uuid4()),
|
||||
"idempotencyKey": f"user:{event['userId']}:signup",
|
||||
"payload": event,
|
||||
}
|
||||
broker.publish(topic=topic, body=json.dumps(message))
|
||||
|
||||
# --- Subscription A: welcome-email service ---
|
||||
def subscribe_welcome(broker, dedup_store, send_email):
|
||||
sub = broker.subscribe(topic="users", subscription="welcome-email")
|
||||
for message in sub.receive():
|
||||
# P3: idempotent per subscription. A redelivered message is
|
||||
# a no-op for THIS subscription, not for the others.
|
||||
if dedup_store.seen(("welcome", message["idempotencyKey"])):
|
||||
sub.ack(message)
|
||||
continue
|
||||
send_email(message["payload"]["email"], "Welcome!")
|
||||
dedup_store.mark(("welcome", message["idempotencyKey"]))
|
||||
sub.ack(message)
|
||||
|
||||
# --- Subscription B: analytics-ingest service ---
|
||||
def subscribe_analytics(broker, dedup_store, ingest):
|
||||
# Independent subscription: its own cursor, its own dedup,
|
||||
# its own ack. Welcome-email acking does NOT advance this.
|
||||
sub = broker.subscribe(topic="users", subscription="analytics")
|
||||
for message in sub.receive():
|
||||
if dedup_store.seen(("analytics", message["idempotencyKey"])):
|
||||
sub.ack(message)
|
||||
continue
|
||||
ingest(message["payload"])
|
||||
dedup_store.mark(("analytics", message["idempotencyKey"]))
|
||||
sub.ack(message)
|
||||
```
|
||||
|
||||
- The dedup key is scoped per subscription: `(subscription,
|
||||
idempotencyKey)`. A redelivery to subscription A that was already
|
||||
processed by A is a no-op for A; the same message delivered to
|
||||
subscription B is processed by B independently. Scoping the dedup
|
||||
key by subscription prevents one subscription's dedup from
|
||||
masking another's redelivery.
|
||||
|
||||
## Fan-Out Semantics (P4, P7)
|
||||
|
||||
- Fan-out means every subscription receives every published message
|
||||
(subject to filtering — see below). The broker duplicates the
|
||||
message per subscription; each subscription's delivery is
|
||||
independent. The fan-out ratio is the number of subscriptions; the
|
||||
broker's cost scales with fan-out × message size.
|
||||
- Partitioning (P7) applies to topics that are partitioned for
|
||||
throughput: a partitioned topic delivers per-partition order, and
|
||||
each subscription receives from every partition. A subscription
|
||||
that consumes partitions in parallel must handle per-partition
|
||||
ordering and cross-partition non-ordering (P2 — document the
|
||||
property, do not assume global order).
|
||||
- The delivery semantic is per-subscription (P4): subscription A
|
||||
may be at-least-once, subscription B may be at-most-once (for a
|
||||
loss-tolerant analytics feed). The choice is per subscription,
|
||||
declared, not emergent.
|
||||
|
||||
## Shared vs Independent Subscriptions (P2, P3 — the chaos anti-pattern)
|
||||
|
||||
- An **independent subscription** is one durable cursor per
|
||||
consumer group: each subscription receives every message in
|
||||
topic order (per partition, P2) and acks independently. This is
|
||||
the correct default: per-consumer ordering and per-consumer
|
||||
idempotency hold.
|
||||
- A **shared subscription** is one subscription shared by multiple
|
||||
consumers: the broker dispatches each message to an arbitrary
|
||||
consumer in the shared group. This breaks per-consumer ordering
|
||||
(P2 — consumer A sees message 3 before consumer B sees message
|
||||
1) and complicates idempotency (P3 — the dedup state must be
|
||||
shared across consumers, not per-consumer). This is the
|
||||
`messaging-shared-subscription` chaos anti-pattern
|
||||
(pre-specified in P4 ATELIER-110): the primary breach is P2
|
||||
(ordering); P3 (idempotency) is the compounding consequence.
|
||||
- A shared subscription is correct ONLY when the consumers are
|
||||
stateless, the per-message processing is order-independent, and
|
||||
the dedup store is shared (a shared Redis, a shared DB). A shared
|
||||
subscription for order-dependent or per-consumer-stateful
|
||||
processing is the chaos anti-pattern: the broker's arbitrary
|
||||
dispatch breaks the order the consumer assumes.
|
||||
|
||||
```python
|
||||
# The messaging-shared-subscription chaos anti-pattern (P2
|
||||
# ordering breach, P3 idempotency compounding). Two consumers
|
||||
# share one subscription; the broker dispatches each message to
|
||||
# an arbitrary consumer. Per-consumer ordering breaks; dedup
|
||||
# must be shared (and often is not).
|
||||
|
||||
# BAD — shared subscription, per-consumer dedup (chaos):
|
||||
def shared_subscription_bad(broker, send_email):
|
||||
# Both consumers call subscribe with the SAME subscription
|
||||
# name. The broker round-robins; consumer A gets msg 1, msg 3;
|
||||
# consumer B gets msg 2, msg 4. Per-consumer order is broken.
|
||||
# If each consumer has its OWN dedup store, a redelivery to
|
||||
# the OTHER consumer re-processes (P3 breach).
|
||||
sub = broker.subscribe(topic="users", subscription="shared")
|
||||
for message in sub.receive():
|
||||
# Per-consumer dedup — WRONG. A redelivered message may
|
||||
# land on the other consumer, which has not seen it.
|
||||
if local_dedup.seen(message["idempotencyKey"]): # per-consumer
|
||||
sub.ack(message); continue
|
||||
send_email(message["payload"]["email"], "Welcome!")
|
||||
local_dedup.mark(message["idempotencyKey"])
|
||||
sub.ack(message)
|
||||
|
||||
# CORRECT — independent subscriptions (per-consumer ordering,
|
||||
# per-subscription dedup):
|
||||
def independent_subscriptions_good(broker, send_email):
|
||||
sub = broker.subscribe(topic="users", subscription="welcome-email")
|
||||
for message in sub.receive():
|
||||
if dedup_store.seen(("welcome", message["idempotencyKey"])):
|
||||
sub.ack(message); continue
|
||||
send_email(message["payload"]["email"], "Welcome!")
|
||||
dedup_store.mark(("welcome", message["idempotencyKey"]))
|
||||
sub.ack(message)
|
||||
```
|
||||
|
||||
- If a shared subscription is genuinely required (stateless,
|
||||
order-independent, shared dedup), document the choice and the
|
||||
shared-dedup requirement (P2 — the ordering property is "none
|
||||
across consumers"; P3 — the dedup is shared). The default is
|
||||
independent subscriptions; shared is an opt-in for the narrow case.
|
||||
|
||||
## Filtering (P4, C8 Economy)
|
||||
|
||||
- **Subscription filtering** lets a subscription receive only
|
||||
messages matching a filter (e.g., `event.type == "order"`).
|
||||
Filtering at the broker saves bandwidth (C8 — the subscriber
|
||||
does not receive and discard) and reduces subscriber load.
|
||||
- **Server-side filtering** (broker evaluates the filter before
|
||||
delivery) is more efficient than **client-side filtering**
|
||||
(subscriber receives and discards). Server-side filtering is the
|
||||
default where the broker supports it (GCP Pub/Sub, SNS filtering,
|
||||
NATS subject filtering); client-side is the fallback.
|
||||
- A filter that is too broad wastes bandwidth; a filter that is
|
||||
too narrow drops messages the subscriber needed. The filter is
|
||||
a P1 (contract) and P4 (semantic) decision: the subscription's
|
||||
filter is part of its declared contract.
|
||||
|
||||
## Ordering Across Subscriptions (P2)
|
||||
|
||||
- A topic with per-partition ordering delivers per-partition order
|
||||
to each subscription. Across subscriptions, there is no ordering
|
||||
guarantee: subscription A may ack message 3 while subscription B
|
||||
is still on message 1. This is correct and expected — each
|
||||
subscription is independent.
|
||||
- Within a subscription, ordering holds per partition (P2 — the
|
||||
documented property). A subscription that processes partitions
|
||||
in parallel must not assume cross-partition order. A subscription
|
||||
that needs global order must use a single partition (sacrificing
|
||||
parallelism, P7) or an external sequencing mechanism.
|
||||
- The `messaging-shared-subscription` anti-pattern breaks even
|
||||
per-partition order within a subscription: the broker's arbitrary
|
||||
dispatch to consumers in the shared group breaks the per-
|
||||
partition sequence each consumer sees.
|
||||
|
||||
## Pub/Sub vs Stream — The Durability Boundary (cross-link messaging/streams)
|
||||
|
||||
- Pub/sub and streams are both fan-out or one-to-many primitives,
|
||||
but their durability model differs. Pub/sub is a
|
||||
**push-to-subscription** model: each subscription is a cursor,
|
||||
retention is short (the subscription's unacked window), and
|
||||
replay is limited to the unacked messages. A subscription that
|
||||
falls behind beyond the retention window loses messages
|
||||
permanently.
|
||||
- A stream is a **durable-log** model: messages are retained by
|
||||
the log for a configured window (P8 — Replay and Retention are
|
||||
Configured), and any consumer group can replay from any offset
|
||||
within the window. A stream consumer that falls behind can
|
||||
catch up by replaying; a pub/sub subscription that falls behind
|
||||
beyond retention cannot.
|
||||
- The choice is the durability requirement: if the consumer must
|
||||
be able to replay (reprocessing, backfill, new consumer starting
|
||||
from the beginning), use a stream. If the consumer only needs
|
||||
the live feed (and can tolerate loss on a long fall-behind),
|
||||
pub/sub is lighter. See `domains/messaging/streams.md` for the
|
||||
durable-log model, offsets, and consumer groups.
|
||||
|
||||
## Observability — Per-Subscription Lag (P10)
|
||||
|
||||
- Per-subscription lag (messages published minus messages acked
|
||||
for each subscription, or the age of the oldest unacked message
|
||||
per subscription) is the primary pub/sub health signal. Each
|
||||
subscription has its own lag — a fast subscription and a slow
|
||||
subscription on the same topic are independent signals.
|
||||
- A subscription whose lag grows beyond the retention window is a
|
||||
silent data-loss risk: the broker will drop the oldest messages,
|
||||
and the subscription will never see them. Alert on lag relative
|
||||
to retention — lag approaching retention is the loss threshold.
|
||||
- Wire per-subscription lag to `domains/observability/metrics` as
|
||||
an SLI per subscription. A topic with N subscriptions has N lag
|
||||
metrics; a single aggregate hides the slow one. See
|
||||
`domains/observability/metrics` for the generic SLI/SLO
|
||||
discipline.
|
||||
|
||||
## What Violates Pub/Sub Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| Shared subscription for order-dependent processing (broker dispatch breaks per-consumer order) | P2 Ordering is a Property, Not an Assumption |
|
||||
| Shared subscription with per-consumer dedup (redelivery to the other consumer re-processes) | P3 Consumers are Idempotent |
|
||||
| Schemaless topic (no versioned contract; subscribers parse by guess) | P1 Messages are Contracts |
|
||||
| Subscription with no idempotency dedup (redelivered message duplicates the effect) | P3 Consumers are Idempotent |
|
||||
| Subscription whose lag approaches retention (silent data loss) | P10, `domains/observability/metrics` |
|
||||
| Unstated delivery semantic per subscription (at-least-once vs at-most-once guessed) | P4 Delivery Semantics are Explicit |
|
||||
| Filter that is too narrow (drops messages the subscriber needed) | P1, P4 |
|
||||
| Partitioned topic with no documented per-partition ordering contract | P2 Ordering is a Property, Not an Assumption |
|
||||
| No per-subscription lag metric (slow subscription invisible) | P10 Messaging is Observable |
|
||||
| Cross-partition order assumption within a subscription (no global order guarantee) | P2, P7 |
|
||||
@@ -0,0 +1,318 @@
|
||||
# Queues — Derived Rules
|
||||
|
||||
> Derives from `domains/messaging/first-principles.md`. Applies P1
|
||||
> (Messages are Contracts), P3 (Consumers are Idempotent), P4
|
||||
> (Delivery Semantics are Explicit), P5 (Dead-Letter Handling is
|
||||
> Defined), and P6 (Backpressure is Bounded) primarily, with P2
|
||||
> (ordering), P7 (partitioning), and P10 (observable lag). For the
|
||||
> at-least-once / at-most-once / exactly-once decision, see the
|
||||
> comparison table below. Cross-links `domains/concurrency/patterns`
|
||||
> for the in-process bounded-queue analog and
|
||||
> `domains/observability/metrics` for consumer lag.
|
||||
|
||||
## What a Queue Is (P1 Messages are Contracts)
|
||||
|
||||
- A queue is a point-to-point async delivery primitive. A producer
|
||||
enqueues a message; exactly one consumer dequeues and processes
|
||||
it. The message has an explicit, versioned schema (P1): the
|
||||
producer and consumer agree on shape before exchange, and the
|
||||
schema is the boundary — a schemaless message is a defect (the
|
||||
consumer breaks silently on the next shape change).
|
||||
- The boundary is per D-062: messaging owns the cross-process /
|
||||
network-failure-model angle; concurrency owns the in-process
|
||||
analog. A queue is a messaging concern because producer and
|
||||
consumer are separate systems, the broker is the intermediary,
|
||||
and the failure model is network (the message can be lost,
|
||||
duplicated, reordered, or delayed by the broker or the network,
|
||||
not by a thread crash). The in-process bounded buffer
|
||||
(`domains/concurrency/patterns` Pattern 5) is the analog below
|
||||
this boundary — it fails by OOM; a broker-backed queue fails by
|
||||
partition, broker restart, or consumer crash-and-retry.
|
||||
- See `domains/messaging/pubsub.md` for the fan-out (one-to-many)
|
||||
variant and `domains/messaging/streams.md` for the durable-log
|
||||
(replay-from-offset) variant. A queue deletes on ack; a stream
|
||||
retains for replay — the durability boundary is the
|
||||
distinguishing trait.
|
||||
|
||||
## Producer / Consumer Model (P1, P3)
|
||||
|
||||
- The producer enqueues a message with an idempotency key (P3).
|
||||
The consumer dequeues, processes, and acks. If the consumer
|
||||
crashes before acking, the broker redelivers; the idempotency
|
||||
key makes the redelivery safe (the consumer dedups, not the
|
||||
broker).
|
||||
- The idempotency key is per-message, not per-producer or
|
||||
per-session. A consumer that dedups by producer alone will drop
|
||||
distinct messages issued in the same window. Use a UUID per
|
||||
message, or a deterministic key derived from the message content
|
||||
(e.g., `(entity, operation, version)`).
|
||||
|
||||
```python
|
||||
# Producer/consumer pair with idempotency key (P1 contract, P3
|
||||
# idempotency). The producer tags each message with a versioned
|
||||
# schema and a unique idempotency key; the consumer dedups by the
|
||||
# key so a redelivered message is processed once (P3).
|
||||
|
||||
# --- Producer ---
|
||||
import json, uuid
|
||||
|
||||
def enqueue(order, broker):
|
||||
# P1: versioned schema. The message carries its schema version
|
||||
# so the consumer can route by shape (P9 evolution discipline).
|
||||
message = {
|
||||
"schema": "order.created.v1",
|
||||
"id": str(uuid.uuid4()),
|
||||
"idempotencyKey": f"order:{order['id']}:{order['version']}",
|
||||
"payload": order,
|
||||
}
|
||||
broker.send(queue="orders", body=json.dumps(message))
|
||||
# At-least-once by default (P4): the broker acks the send; the
|
||||
# consumer may see this message more than once under retry.
|
||||
|
||||
# --- Consumer ---
|
||||
def consume(broker, dedup_store, process_order):
|
||||
for message in broker.receive(queue="orders"):
|
||||
# P3: idempotent consumer. Dedup by idempotency key before
|
||||
# processing; a redelivered message is a no-op, not a
|
||||
# double-apply.
|
||||
if dedup_store.seen(message["idempotencyKey"]):
|
||||
broker.ack(message) # already processed; skip
|
||||
continue
|
||||
try:
|
||||
process_order(message["payload"])
|
||||
dedup_store.mark(message["idempotencyKey"])
|
||||
broker.ack(message) # success; broker drops it
|
||||
except Exception:
|
||||
broker.nack(message) # redeliver (at-least-once, P4)
|
||||
```
|
||||
|
||||
- The dedup store is bounded (P6 — Backpressure is Bounded): a
|
||||
dedup store that grows without bound is a memory leak. Use a TTL
|
||||
window longer than the broker's max-redelivery window, or a
|
||||
bounded LRU. See `domains/messaging/delivery-semantics.md` for
|
||||
the full idempotency-key dedup-store pattern.
|
||||
|
||||
## Ack / Nack (P4 Delivery Semantics are Explicit)
|
||||
|
||||
- **Ack** tells the broker the message was processed; the broker
|
||||
drops it. **Nack** (negative ack) tells the broker the
|
||||
processing failed; the broker redelivers (at-least-once) or
|
||||
routes to a DLQ (after the retry budget — P5).
|
||||
- A consumer that neither acks nor nacks within the visibility
|
||||
timeout causes the broker to redeliver (the broker assumes the
|
||||
consumer died). This is the at-least-once default: the broker
|
||||
prefers duplication to loss.
|
||||
- The semantic is explicit (P4): at-least-once is the default; the
|
||||
consumer must be idempotent (P3). At-most-once is fire-and-forget
|
||||
(no ack; the broker drops on send) — lossy but lowest latency.
|
||||
Exactly-once is at-least-once plus idempotency, or a
|
||||
transactional two-phase commit — see the comparison table below.
|
||||
|
||||
## Visibility Timeouts and Redelivery (P4, P5)
|
||||
|
||||
- The visibility timeout is the window the broker hides a message
|
||||
after delivery, waiting for the ack. If the consumer does not
|
||||
ack within the window, the broker makes the message visible
|
||||
again and redelivers it (to the same consumer or another). This
|
||||
is the at-least-once mechanism: the broker assumes a
|
||||
no-ack-in-time consumer is dead.
|
||||
- The timeout must be longer than the processing time, or the
|
||||
broker redelivers a message the consumer is still processing —
|
||||
causing duplicate processing (which P3 idempotency makes safe,
|
||||
but which wastes resources). A timeout shorter than processing
|
||||
time is a P6 (backpressure) smell: the consumer is too slow for
|
||||
the configured timeout.
|
||||
- Redelivery has a budget (P5): after N redeliveries or a TTL, the
|
||||
message routes to the DLQ. An unbounded retry budget is the
|
||||
`messaging-unbounded-retry` chaos anti-pattern: the consumer
|
||||
never makes progress past the poison message.
|
||||
|
||||
## FIFO vs Standard Queues (P2 Ordering is a Property, Not an Assumption)
|
||||
|
||||
- A **standard queue** delivers in arrival order per receive-node
|
||||
but offers no global ordering across shards, no per-message-
|
||||
group ordering, and may redeliver out of order under retry. It
|
||||
is the high-throughput default; ordering is *not* guaranteed
|
||||
(P2: the ordering property is "none" — explicitly documented).
|
||||
- A **FIFO queue** delivers strict per-message-group order: all
|
||||
messages with the same group ID are delivered to one consumer
|
||||
in send order. The cost is throughput (FIFO queues cap at lower
|
||||
TPS) and latency (the broker must sequence per group). The
|
||||
ordering property is "per-group strict" — explicitly documented
|
||||
(P2).
|
||||
- The choice is a P2 decision (which ordering guarantee) and a C8
|
||||
decision (throughput cost). A consumer that assumes FIFO on a
|
||||
standard queue is a P2 violation: the broker does not provide
|
||||
the guarantee the consumer assumes. Document the property; do
|
||||
not assume it.
|
||||
|
||||
## Prefetch and Concurrency (P6 Backpressure is Bounded)
|
||||
|
||||
- **Prefetch** (or max-unacked) bounds how many messages the
|
||||
broker delivers to one consumer without an ack. A prefetch of 1
|
||||
is strict stop-and-wait (lowest throughput, tightest backpressure);
|
||||
a prefetch of N allows the consumer to process N in flight
|
||||
(higher throughput, more memory). An unbounded prefetch is a P6
|
||||
violation: the broker floods the consumer's memory.
|
||||
- **Consumer concurrency** is the number of parallel workers
|
||||
processing from the queue. More workers increase throughput up to
|
||||
the downstream's limit; beyond that, the workers stall the
|
||||
downstream (P6 — the backpressure propagates to the
|
||||
downstream, not the broker).
|
||||
- The prefetch × concurrency product is the in-flight cap. Declare
|
||||
it (P6): an undeclared cap is a defect — the consumer either
|
||||
underutilizes the broker (prefetch too low) or OOMs under load
|
||||
(prefetch too high). This is the cross-process analog of
|
||||
`domains/concurrency/patterns` Pattern 5 (bounded queue with
|
||||
backpressure): concurrency owns the in-process analog;
|
||||
messaging owns the broker-backed instance.
|
||||
|
||||
## Long Polling (P6, C8 Economy)
|
||||
|
||||
- Long polling (or `ReceiveMessage` with a wait-time-seconds)
|
||||
holds the receive request open until a message arrives or the
|
||||
wait expires. This reduces empty-receive round trips (C8
|
||||
economy of the constrained link) and reduces latency-to-first-
|
||||
message (the message is delivered when it arrives, not on the
|
||||
next poll cycle).
|
||||
- Long polling is the default for low-throughput queues: short
|
||||
polling burns CPU on empty receives; long polling waits for
|
||||
work. For high-throughput queues, the broker is usually full
|
||||
enough that long polling adds no latency; for low-throughput
|
||||
queues, long polling is the difference between 20ms and 20s
|
||||
latency-to-first-message.
|
||||
|
||||
## Redelivery + DLQ Flow (P5 Dead-Letter Handling is Defined)
|
||||
|
||||
- A poison message (unparseable, repeatedly failing, or exhausting
|
||||
the retry budget) routes to the dead-letter queue. The DLQ is
|
||||
observable (P10 — DLQ depth is an alert) and drainable (an
|
||||
operator can inspect, replay, or discard with audit).
|
||||
- The retry budget is bounded (P6): N redeliveries, or a TTL with
|
||||
exponential backoff. After the budget is exhausted, the message
|
||||
is moved to the DLQ, not retried forever. An unbounded retry is
|
||||
the `messaging-unbounded-retry` chaos anti-pattern (P5 breach).
|
||||
- The DLQ routing rule is a redelivery-count or TTL threshold
|
||||
plus a target queue. See `domains/messaging/delivery-semantics.md`
|
||||
for the dead-letter strategy comparison table.
|
||||
|
||||
```python
|
||||
# Redelivery + DLQ flow (P5 dead-letter handling, P6 bounded
|
||||
# retry budget). The consumer tracks redelivery count; after the
|
||||
# budget, the message routes to the DLQ. The DLQ is observable
|
||||
# (P10 — depth is an alert) and drainable.
|
||||
|
||||
MAX_REDELIVERIES = 5
|
||||
DLQ = "orders-dlq"
|
||||
|
||||
def consume_with_dlq(broker, dedup_store, process_order):
|
||||
for message in broker.receive(queue="orders"):
|
||||
# P3 idempotency: a redelivered, already-processed message
|
||||
# is acked and skipped (not re-processed, not DLQ'd).
|
||||
if dedup_store.seen(message["idempotencyKey"]):
|
||||
broker.ack(message)
|
||||
continue
|
||||
try:
|
||||
process_order(message["payload"])
|
||||
dedup_store.mark(message["idempotencyKey"])
|
||||
broker.ack(message)
|
||||
except Exception as exc:
|
||||
# P5: bounded retry budget. After MAX_REDELIVERIES,
|
||||
# route to DLQ — do NOT retry forever.
|
||||
count = message.get("redeliveryCount", 0) + 1
|
||||
if count >= MAX_REDELIVERIES:
|
||||
broker.send(DLQ, body=json.dumps({
|
||||
"original": message,
|
||||
"reason": str(exc),
|
||||
"deadLetteredAt": now_iso(),
|
||||
"redeliveryCount": count,
|
||||
}))
|
||||
broker.ack(message) # remove from the origin queue
|
||||
# P10: the DLQ depth must alert. A DLQ that grows
|
||||
# with no alert is a silent correctness defect.
|
||||
else:
|
||||
# Nack with backoff: the broker redelivers after a
|
||||
# delay. The backoff caps the retry rate (P6).
|
||||
broker.nack(message, delay=exponential_backoff(count))
|
||||
```
|
||||
|
||||
- The `deadLetteredAt` and `reason` fields make the DLQ entry
|
||||
observable and auditable: an operator inspecting the DLQ sees
|
||||
why each message was dead-lettered and when. See
|
||||
`domains/errors/patterns` for the errors-as-data discipline the
|
||||
DLQ entry follows.
|
||||
|
||||
## Delivery Semantics Comparison (D-069)
|
||||
|
||||
| Semantic | Guarantee | Latency Cost | Implementation Cost | When It Fits |
|
||||
|----------|-----------|-------------|---------------------|--------------|
|
||||
| **At-most-once** | A message is delivered 0 or 1 times; loss is possible, duplication is not | Lowest (no ack; fire-and-forget) | Lowest (no ack, no dedup) | Telemetry where a dropped sample is acceptable; high-throughput metrics; MQTT QoS 0; logs where a lost line is tolerable. Never for billing, orders, or any side-effecting operation. |
|
||||
| **At-least-once** | A message is delivered 1 or more times; duplication is possible, loss is not | Low (one ack round-trip) | Medium (consumer must be idempotent — P3; dedup store required) | The default for side-effecting operations: orders, payments, commands. The consumer dedups via idempotency keys (P3); the broker guarantees delivery. Fits the vast majority of broker-backed queues (SQS standard, RabbitMQ ack, MQTT QoS 1). |
|
||||
| **Exactly-once** | A message is delivered exactly 1 time; no loss, no duplication | Highest (two-phase commit or transactional producer+consumer) | Highest (requires transactions, a transactional producer, and a transactional consumer — or at-least-once plus idempotency, which collapses to at-least-once with dedup) | Rare. Kafka transactions (consume-process-produce in one transaction); MQTT QoS 2 (four-step handshake). In practice, "exactly-once" is usually at-least-once plus idempotency (P3) — the broker does not guarantee it; the consumer enforces it. Jepsen analyses verify broker claims. |
|
||||
|
||||
- The default for side-effecting operations is **at-least-once with
|
||||
idempotent consumers** (P3). At-most-once is for loss-tolerant
|
||||
telemetry. Exactly-once is reserved for the narrow case where
|
||||
the consume-process-produce loop must be transactional (Kafka
|
||||
transactions) — and even then, the consumer should be idempotent
|
||||
as defense-in-depth.
|
||||
- The latency cost column is the C8 tradeoff: at-most-once is
|
||||
cheapest, exactly-once is most expensive. The implementation
|
||||
cost column is the C1/C3 tradeoff: at-most-once is simplest,
|
||||
exactly-once is most complex (and most fragile — a transactional
|
||||
consumer that partially fails is a bug source). The "when it
|
||||
fits" column is the P4 decision: declare the semantic per
|
||||
channel, do not let it emerge.
|
||||
- This is the decision matrix required by D-069 for queues; see
|
||||
`domains/messaging/delivery-semantics.md` for the correctness
|
||||
properties of each semantic and the dead-letter strategy
|
||||
comparison.
|
||||
|
||||
## Observability — Consumer Lag (P10 Messaging is Observable)
|
||||
|
||||
- Consumer lag (messages enqueued minus messages acked, or the
|
||||
age of the oldest unacked message) is the primary queue health
|
||||
signal. A lag that grows without bound is a P6 violation (the
|
||||
consumer is slower than the producer) and a P10 violation if it
|
||||
is not alerted.
|
||||
- DLQ depth is the secondary signal: a DLQ that grows is a P5
|
||||
signal (poison messages are accumulating) and a P10 signal if
|
||||
not alerted. Wire both to `domains/observability/metrics` as
|
||||
SLIs with SLOs (e.g., lag < 1000 messages, DLQ depth < 10).
|
||||
- A queue with no lag metric is operating blind (P10 violation):
|
||||
the operator cannot see the consumer falling behind until the
|
||||
downstream effect surfaces.
|
||||
|
||||
## Cross-Link to Concurrency (P6, cross-link concurrency/patterns)
|
||||
|
||||
- The broker-backed queue is the cross-process analog of the
|
||||
in-process bounded buffer. `domains/concurrency/patterns`
|
||||
Pattern 5 (Bounded Queue with Backpressure) owns the in-process
|
||||
instance ("producer is blocked or signaled" within one program);
|
||||
messaging owns the broker-backed instance above it (the producer
|
||||
is the broker's enqueue, the consumer is the broker's dequeue, the
|
||||
backpressure is the prefetch cap and the lag signal). The
|
||||
failure model differs: in-process fails by OOM; broker-backed
|
||||
fails by network partition, broker restart, or consumer
|
||||
crash-and-retry (D-062).
|
||||
- The cross-link is one-directional outward (messaging →
|
||||
concurrency) per D-026 extended: messaging references concurrency
|
||||
as the in-process foundation; concurrency does not back-link to
|
||||
messaging.
|
||||
|
||||
## What Violates Queue Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| Schemaless message (no versioned contract; consumer parses by guess) | P1 Messages are Contracts |
|
||||
| Non-idempotent consumer under at-least-once delivery (redelivery doubles the effect) | P3 Consumers are Idempotent |
|
||||
| Unstated delivery semantic (at-least-once vs exactly-once guessed) | P4 Delivery Semantics are Explicit |
|
||||
| No DLQ (poison message retried forever or silently dropped) | P5 Dead-Letter Handling is Defined |
|
||||
| Unbounded prefetch (broker floods consumer memory) | P6 Backpressure is Bounded |
|
||||
| Unbounded retry budget (no cap; consumer never progresses past the poison) | P5, P6 |
|
||||
| Consumer that assumes FIFO on a standard queue (ordering not guaranteed) | P2 Ordering is a Property, Not an Assumption |
|
||||
| Visibility timeout shorter than processing time (redeliver while still processing) | P4, P6 |
|
||||
| DLQ with no depth alert (poison messages accumulate invisibly) | P10, `domains/observability/metrics` |
|
||||
| No consumer-lag metric (consumer falls behind invisibly) | P10, `domains/observability/metrics` |
|
||||
| Dedup store that grows without bound (memory leak) | P6 Backpressure is Bounded |
|
||||
| Default prefetch with no rationale (underutilizes or OOMs) | P6, `domains/concurrency/patterns` |
|
||||
@@ -0,0 +1,329 @@
|
||||
# Streams — Derived Rules
|
||||
|
||||
> Derives from `domains/messaging/first-principles.md`. Applies P8
|
||||
> (Replay and Retention are Configured) primarily, with P2
|
||||
> (per-partition ordering), P3 (idempotent consumers), P4 (exactly-
|
||||
> once via transactions), P7 (partitioning), and P10 (observable
|
||||
> consumer-group health). For the Kafka / Kinesis / Pulsar /
|
||||
> NATS JetStream decision, see the stream-platform comparison
|
||||
> table below. For the consumer-group rebalance strategy choice,
|
||||
> see the rebalance enumeration below (IDEATE-41). Cross-links
|
||||
> `domains/messaging/delivery-semantics` for exactly-once via
|
||||
> transactions, `domains/data/schema-design` for stream schema,
|
||||
> and `domains/observability/tracing` for cross-partition traces.
|
||||
|
||||
## What a Stream Is (P8 Replay and Retention are Configured)
|
||||
|
||||
- A stream is a durable-log messaging primitive. Messages are
|
||||
appended to a partitioned, replicated log; consumers read from an
|
||||
offset and advance at their own pace. The log is retained for a
|
||||
configured window (P8) — a stream is a durable log, not a pipe.
|
||||
A consumer that falls behind can catch up by replaying from an
|
||||
earlier offset; a consumer that starts fresh can replay from the
|
||||
beginning (within retention).
|
||||
- The boundary with queues and pub/sub is the durability model. A
|
||||
queue deletes on ack; a pub/sub subscription retains only its
|
||||
unacked window; a stream retains the whole log for the configured
|
||||
retention. This makes a stream replayable (P8 — the
|
||||
reversibility mechanism) and observable as a record (P10 — the
|
||||
log is itself an audit of what happened). See
|
||||
`domains/messaging/pubsub.md` for the pub/sub-vs-stream
|
||||
durability boundary discussion.
|
||||
- The boundary with concurrency is per D-062: messaging owns the
|
||||
cross-process/network-failure-model angle. A stream is a
|
||||
messaging concern because the log spans brokers and consumers
|
||||
across a network, and the failure model is partition, broker
|
||||
restart, or consumer crash-and-retry — not in-process OOM or
|
||||
thread crash.
|
||||
|
||||
## Partitioned Log Model (P2, P7)
|
||||
|
||||
- A stream is partitioned for throughput and parallelism. Each
|
||||
partition is an ordered, append-only log; messages within a
|
||||
partition are strictly ordered (P2 — per-partition strict order
|
||||
is the documented property). Across partitions, there is no
|
||||
ordering guarantee: partition 0 and partition 1 are independent
|
||||
logs.
|
||||
- The partition key (P7 — Partitioning is Intentional) determines
|
||||
which partition a message lands on. A key that hashes evenly
|
||||
spreads load; a key that matches the per-entity ordering need
|
||||
(e.g., `userId` for user events) keeps a user's events on one
|
||||
partition in order; a key that is too coarse (one partition for
|
||||
the whole topic) serializes all traffic.
|
||||
- The partition count is a capacity bound: it caps the parallelism
|
||||
(one consumer per partition per consumer group) and the
|
||||
throughput (each partition has a write-throughput limit). Too
|
||||
few partitions cap parallelism; too many partition overhead the
|
||||
broker (file handles, replication, rebalance cost). The choice
|
||||
is documented (P7), not defaulted.
|
||||
|
||||
## Offsets (P2, P8)
|
||||
|
||||
- An offset is a consumer's position in a partition. The consumer
|
||||
reads from its last committed offset; acking (committing the
|
||||
offset) advances it. A consumer that crashes before committing
|
||||
re-reads from the last committed offset (at-least-once by
|
||||
default, P4) — the consumer must be idempotent (P3).
|
||||
- The offset is per-partition (P2): each partition has its own
|
||||
position, and the consumer commits them independently (or
|
||||
atomically across partitions in a transaction — see below).
|
||||
- Replay (P8) is resetting the offset backward: a consumer can
|
||||
replay from the beginning of retention, from a timestamp, or
|
||||
from a specific offset. This is the durable-log property that
|
||||
distinguishes a stream from a queue.
|
||||
|
||||
## Consumer Groups (P3, P7, P10)
|
||||
|
||||
- A consumer group is a set of consumers sharing the stream's
|
||||
partitions: each partition is assigned to exactly one consumer
|
||||
in the group. The group is the unit of parallelism and the unit
|
||||
of offset tracking. Within a group, each consumer handles its
|
||||
assigned partitions; across groups, each group independently
|
||||
reads the whole stream (the pub/sub fan-out property, per
|
||||
subscription/group).
|
||||
- A consumer in the group is idempotent (P3): under at-least-once
|
||||
(the default), a redelivery after a crash-and-retry re-processes
|
||||
messages. The consumer dedups by idempotency key, or processes
|
||||
deterministically (e.g., a stateful aggregation that overwrites
|
||||
with the latest value).
|
||||
- The consumer group's health is observable (P10): per-partition
|
||||
lag (offset of the consumer vs the log's head), the group's
|
||||
consumption rate, and rebalance events are first-class signals.
|
||||
A group whose lag grows without bound is a P6 (backpressure)
|
||||
smell and a P10 (observability) violation if not alerted.
|
||||
|
||||
```python
|
||||
# Consumer-group reading from offsets (P2 per-partition order,
|
||||
# P3 idempotent under at-least-once, P7 partition assignment,
|
||||
# P8 replay from offset). Each consumer in the group handles its
|
||||
# assigned partitions; the group commits offsets atomically or
|
||||
# per-partition.
|
||||
|
||||
def consume_stream(stream, group, dedup_store, process_event):
|
||||
# Assign partitions to this consumer by the group's
|
||||
# rebalance strategy (see the enumeration below).
|
||||
for partition in stream.assigned_partitions(group, consumer=ME):
|
||||
# Read from the last committed offset (P8 — replay by
|
||||
# resetting this offset).
|
||||
offset = stream.committed_offset(group, partition)
|
||||
for message in stream.read(partition, from_offset=offset):
|
||||
# P3: idempotent under at-least-once. A redelivery
|
||||
# after a crash-and-retry re-processes; dedup by key.
|
||||
if dedup_store.seen(message["idempotencyKey"]):
|
||||
stream.commit(group, partition, message["offset"])
|
||||
continue
|
||||
process_event(message["payload"])
|
||||
dedup_store.mark(message["idempotencyKey"])
|
||||
# Commit the offset to advance (P8 — the position is
|
||||
# the replay pointer).
|
||||
stream.commit(group, partition, message["offset"])
|
||||
```
|
||||
|
||||
- The commit-after-process order gives at-least-once (a crash
|
||||
before commit re-reads); the commit-before-process order gives
|
||||
at-most-once (a crash after commit loses the unprocessed
|
||||
message). The default is at-least-once with idempotent consumers
|
||||
(P3, P4).
|
||||
|
||||
## Consumer-Group Rebalance Strategies (IDEATE-41, ATELIER-100 refinement)
|
||||
|
||||
When a consumer joins or leaves the group, the broker must
|
||||
reassign partitions. The rebalance strategy determines the cost
|
||||
and the use-case fit. This enumeration parallels the v0.3
|
||||
IDEATE-30 drift-type enumeration (each strategy with its
|
||||
stop-the-world cost and use-case fit).
|
||||
|
||||
| Strategy | Mechanism | Partition Stop-the-World Cost | Use-Case Fit |
|
||||
|----------|-----------|-------------------------------|--------------|
|
||||
| **Eager rebalance** (stop-the-world) | Every consumer in the group revokes ALL its partitions, the broker reassigns the full partition set, then consumers resume. Every rebalance pauses the whole group. | High — every partition pauses for every rebalance; the whole group stops processing during the revocation+reassignment window. Throughput drops to zero during rebalance. | Simple brokers, small groups, or rarely-rebalancing groups where the simplicity of full revocation outweighs the pause cost. Kafka's legacy protocol (pre-2.4). Avoid for large groups or frequent scale events. |
|
||||
| **Sticky (incremental cooperative) rebalance** | The broker reassigns only the partitions that must move (the joining/leaving consumer's share); existing partitions stay assigned. The rebalance is incremental and cooperative — no full revocation. | Low — only the moving partitions pause; the rest of the group continues processing. The pause is proportional to the changed partition count, not the total. | The default for large groups, frequent scale events, and rolling deploys. Kafka's CooperativeStickyAssignor (2.4+), Pulsar, NATS JetStream. Prefer for any group where a full stop-the-world on every deploy is unacceptable. |
|
||||
| **Cooperative (no-revoke) rebalance** | A subset of sticky where no partition is revoked unless the consumer leaves; only additions are incremental. The strictest minimization of stop-the-world. | Lowest — only added partitions pause; existing assignments are untouched. | Groups where partition assignment is append-only (consumers join but rarely leave). Useful for long-lived consumers with incremental scaling. |
|
||||
|
||||
- The default for any non-trivial group is **sticky/cooperative**:
|
||||
a rolling deploy that triggers an eager rebalance pauses the
|
||||
whole group on every pod restart, which is unacceptable at
|
||||
scale. The eager strategy is a legacy default that survives
|
||||
because it is simple; prefer sticky where the broker supports
|
||||
it.
|
||||
- The "partition stop-the-world cost" column is the P6
|
||||
(backpressure) angle: a full stop-the-world during rebalance
|
||||
causes lag to spike (the consumer is paused, the producer is
|
||||
not). Sticky rebalance bounds the spike to the moving partitions.
|
||||
- A rebalance that pauses without a lag alert is a P10 violation:
|
||||
the operator cannot see the rebalance-induced lag. Wire rebalance
|
||||
events to `domains/observability/metrics` as an event signal.
|
||||
|
||||
## Replay and Retention Windows (P8, C5 Reversibility)
|
||||
|
||||
- Retention is the configured window the log keeps messages: time-
|
||||
based (e.g., 7 days), size-based (e.g., 10 GB per partition), or
|
||||
compacted (keep the latest value per key — a changelog). A
|
||||
stream with no retention is a pipe, not a log (P8 violation); a
|
||||
stream with infinite retention grows until disk exhaustion (P6
|
||||
violation — backpressure on the broker).
|
||||
- Replay (P8) is resetting a consumer's offset to re-read from
|
||||
within the retention window. Use cases: reprocessing after a
|
||||
consumer bug (replay from the timestamp of the buggy deploy),
|
||||
backfilling a new consumer (replay from the beginning), or
|
||||
reindexing (replay to rebuild a derived store).
|
||||
- Compacted topics (Kafka log-compaction, Pulsar compaction) keep
|
||||
the latest value per key and discard older values for the same
|
||||
key. This turns the log into a changelog — a durable
|
||||
materialized view that replays to the current state. Compaction
|
||||
is a P8 (retention) and C5 (reversibility) mechanism: the log
|
||||
retains the current state per key and is replayable to it.
|
||||
|
||||
## Stream Processing (P2, P3, P4)
|
||||
|
||||
- Stream processing is computing over the stream as it arrives:
|
||||
windowing (tumbling, sliding, session windows), joins (stream-
|
||||
stream, stream-table), aggregations (count, sum, per-key
|
||||
windows), and stateful transformations. The processing is
|
||||
per-partition ordered (P2 — a window over a partition is
|
||||
deterministic; a window across partitions is not unless the
|
||||
window is global).
|
||||
- Stream processing consumers are idempotent (P3): a redelivery
|
||||
after a crash re-processes a window; the aggregation must
|
||||
tolerate re-application (e.g., a sum is idempotent under replay
|
||||
if the window is keyed by offset range, not by wall time).
|
||||
- Exactly-once stream processing (P4) requires transactions: the
|
||||
consume-process-produce loop is one transaction — the input
|
||||
offset commit and the output produce are atomic. See the
|
||||
transactional exactly-once producer below.
|
||||
|
||||
## Exactly-Once via Transactions (P4, P3)
|
||||
|
||||
- Exactly-once stream processing is at-least-once plus a
|
||||
transaction: the consumer commits the input offset and produces
|
||||
the output in one transactional operation. If the consumer
|
||||
crashes mid-transaction, neither the offset commit nor the
|
||||
output produce happens — the consumer re-reads from the last
|
||||
committed offset and re-processes (at-least-once), but the
|
||||
transaction ensures the output is produced exactly once.
|
||||
- This is NOT a broker guarantee of exactly-once delivery; it is
|
||||
at-least-once delivery plus idempotent/transactional processing
|
||||
(P3, P4). Jepsen analyses of Kafka transactions confirm the
|
||||
boundaries: the transaction is atomic within the broker, but
|
||||
the downstream sink must be transactional or idempotent too.
|
||||
- See `domains/messaging/delivery-semantics.md` for the full
|
||||
exactly-once-via-idempotency discussion.
|
||||
|
||||
```python
|
||||
# Transactional exactly-once producer (P4 exactly-once via
|
||||
# transactions, P3 idempotent produce). The consume-process-
|
||||
# produce loop is one transaction: the input offset commit and
|
||||
# the output produce are atomic. A crash mid-transaction rolls
|
||||
# both back; the consumer re-reads and re-processes.
|
||||
|
||||
def consume_transform_produce(stream, group, txn_producer):
|
||||
# Begin a transaction. All produces and the offset commit in
|
||||
# this block are atomic (P4).
|
||||
with txn_producer.transaction() as txn:
|
||||
for partition in stream.assigned_partitions(group, ME):
|
||||
offset = stream.committed_offset(group, partition)
|
||||
for message in stream.read(partition, from_offset=offset):
|
||||
output = transform(message["payload"])
|
||||
# P3: idempotent produce. The txn producer
|
||||
# dedups by an epoch+sequence so a retried
|
||||
# transaction does not double-produce.
|
||||
txn.produce(
|
||||
topic="enriched-events",
|
||||
key=message["key"],
|
||||
value=output,
|
||||
idempotencyKey=message["idempotencyKey"],
|
||||
)
|
||||
# Commit the input offset within the same
|
||||
# transaction (P4 atomicity). A crash before
|
||||
# txn.commit() rolls this back; the consumer
|
||||
# re-reads from the prior offset.
|
||||
txn.commit_offset(group, partition, message["offset"])
|
||||
# txn.commit() makes the produces and the offset commit
|
||||
# visible atomically. A crash before this point aborts
|
||||
# both; a crash after is safe (idempotent produce — P3).
|
||||
```
|
||||
|
||||
- The transactional producer's idempotency (P3) is the defense
|
||||
against a retried transaction: the broker dedups the output by
|
||||
the producer's epoch and sequence so a re-commit does not
|
||||
double-produce. The transaction (P4) is the defense against a
|
||||
partial failure: the offset and the output commit together.
|
||||
|
||||
## Stream Schema (P1, P9, cross-link data/schema-design)
|
||||
|
||||
- A stream's messages carry a versioned schema (P1). The schema
|
||||
evolves compatibly (P9): a new field the old consumer ignores is
|
||||
backward-compatible; a renamed field the old consumer parses as
|
||||
`undefined` is a P1 violation compounded as an evolution defect.
|
||||
- Stream schemas are often registered in a schema registry
|
||||
(Confluent, Apicurio) that enforces compatibility on produce.
|
||||
A producer that tries to publish an incompatible schema is
|
||||
rejected; the registry is the P1/P9 enforcement point.
|
||||
- See `domains/data/schema-design` for the generic schema-design
|
||||
discipline (Avro, Protobuf, JSON Schema); messaging owns the
|
||||
stream-specific instance — the registry, the per-topic
|
||||
compatibility mode, the consumer-side routing by schema
|
||||
version.
|
||||
|
||||
## Stream-Platform Comparison (D-069)
|
||||
|
||||
| Axis | Apache Kafka | AWS Kinesis | Apache Pulsar | NATS JetStream |
|
||||
|------|--------------|-------------|---------------|----------------|
|
||||
| **Ordering** | Per-partition strict (P2); global only via single-partition topic | Per-shard strict; global only via single shard | Per-partition strict; global via single partition; also supports shared (out-of-order) subscriptions | Per-stream strict; per-subject ordering; global via single stream |
|
||||
| **Partitioning model** | Partitions (immutable count post-creation; increase requires recreate); key→partition by hash | Shards (reshardable: split/merge at runtime); key→shard by hash | Partitions (resizable; Pulsar's layered architecture separates compute from storage); key→partition by hash | Streams (subject-based; republish to resize); key→stream by subject |
|
||||
| **Replay / retention** | Time- or size-based retention; compaction (latest-per-key); replay from offset or timestamp | Time-based retention (24h–365d); replay from sequence number or timestamp; no compaction | Time- or size-based; compaction; replay from offset or timestamp; tiered storage (hot/warm/cold) | Time- or size-based; per-stream max-age; replay from sequence; no native compaction |
|
||||
| **Consumer groups** | Group-coordinated; offsets stored in an internal topic; eager (legacy) or sticky/cooperative (2.4+) rebalance | Enhanced fan-out consumers (per-shard HTTP/2 push); KCL for group coordination; no native group rebalance (shard is the unit) | Group-coordinated; shared or failover subscription modes; cooperative rebalance | Per-stream consumers; durable cursors; no native group rebalance (stream is the unit) |
|
||||
| **Exactly-once** | Transactions (KIP-98): consume-process-produce atomic; idempotent producer (KIP-516) | No native exactly-once; at-least-once with consumer-side dedup (P3) | Transactions: produce-ack atomic; idempotent producer | At-least-once by default; dedup window per stream (P3 idempotency) |
|
||||
| **Use-case fit** | High-throughput durable logs, stream processing (Kafka Streams, Flink), event sourcing, multi-consumer replay | AWS-native streaming, log ingestion, simple ETL within AWS; low operational burden | Cloud-native, geo-replication, tiered storage, mixed pub/sub + streaming; multi-tenant | Lightweight, low-latency, edge-friendly; NATS ecosystem; simpler ops than Kafka |
|
||||
| **Watch out for** | Partition count is fixed at creation (resize requires recreate + republish); rebalance cost on large groups; operational complexity | Shard limits per account; no compaction; retention cap at 365 days; AWS lock-in | Two-arch (BookKeeper + Brokers) operational complexity; smaller ecosystem | Smaller ecosystem; no native compaction; fewer stream-processing libraries |
|
||||
|
||||
- The default for high-throughput durable logs with multi-consumer
|
||||
replay is **Kafka**; for AWS-native streaming, **Kinesis**; for
|
||||
geo-replicated multi-tenant or mixed pub/sub + streaming,
|
||||
**Pulsar**; for lightweight low-latency edge-friendly streaming,
|
||||
**NATS JetStream** (which also cross-links `domains/edge/iot`
|
||||
via MQTT parallels — see the edge↔messaging bidirectionality in
|
||||
`domains/messaging/first-principles.md` §4).
|
||||
- The ordering column is the P2 check: every platform provides
|
||||
per-partition/per-shard strict order; none provides global order
|
||||
across partitions except by single-partition. The replay/
|
||||
retention column is the P8 check: every platform retains for a
|
||||
configured window; replay is from offset or timestamp. The
|
||||
exactly-once column is the P4 check: Kafka and Pulsar provide
|
||||
transactions; Kinesis and JetStream rely on at-least-once plus
|
||||
consumer-side idempotency (P3).
|
||||
|
||||
## Cross-Partition Traces (P10, cross-link observability/tracing)
|
||||
|
||||
- A stream-processing pipeline that fans out across partitions
|
||||
must propagate a trace context per event: the trace ID follows
|
||||
the event from source to processed output, even as the event
|
||||
crosses partition boundaries. Without cross-partion traces, a
|
||||
downstream error cannot be traced back to its source event.
|
||||
- See `domains/observability/tracing` for the generic distributed-
|
||||
tracing discipline (trace context propagation, span
|
||||
correlation). Messaging owns the stream-specific instance: the
|
||||
trace context is a message header, the span boundary is the
|
||||
consume-process-produce edge, and the cross-partition
|
||||
correlation is by trace ID (not by partition — partitions are
|
||||
independent logs, P2).
|
||||
- A stream processor with no trace propagation is a P10
|
||||
violation: the operator cannot trace a processed event back to
|
||||
its source. Wire the trace context into every produce and every
|
||||
consume.
|
||||
|
||||
## What Violates Stream Discipline
|
||||
|
||||
| Violation | Principle |
|
||||
|-----------|-----------|
|
||||
| Stream with no retention (pipe, not log; no replay) | P8 Replay and Retention are Configured |
|
||||
| Infinite retention (grows until disk exhaustion) | P8, P6 |
|
||||
| Consumer group with eager rebalance at scale (full stop-the-world per deploy) | P6, IDEATE-41 (use sticky/cooperative) |
|
||||
| Non-idempotent stream consumer under at-least-once (redelivery re-processes the window) | P3 Consumers are Idempotent |
|
||||
| Default partition key (no rationale; hotspot or wrong-order) | P7 Partitioning is Intentional |
|
||||
| Partition count too low (caps parallelism) or too high (overhead) | P7 |
|
||||
| Cross-partition order assumption (no global order guarantee) | P2 Ordering is a Property, Not an Assumption |
|
||||
| Exactly-once claimed without transactional consume-process-produce (P4 violation) | P4 Delivery Semantics are Explicit |
|
||||
| Stream schema with no registry / no compatibility enforcement (silent shape break) | P1, P9, `domains/data/schema-design` |
|
||||
| No per-partition lag metric (consumer falls behind invisibly) | P10, `domains/observability/metrics` |
|
||||
| No cross-partition trace propagation (downstream error untraceable) | P10, `domains/observability/tracing` |
|
||||
| Compacted topic treated as a full log (old values already discarded) | P8, C1 (compaction is a retention mode, not a full log) |
|
||||
Reference in New Issue
Block a user