Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:
uv venv --python 3.14 .venv-minimal
VIRTUAL_ENV=.venv-minimal uv pip install .
VIRTUAL_ENV=.venv-minimal uv run --no-project python -c "
import kit, kit.health, kit.httpapi, kit.config, kit.observability, kit.email
import kit, kit.health, kit.httpapi, kit.config, kit.observability, kit.email, kit.messaging
print('kit', kit.__version__, 'imports without the otel extra')
"

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ from kit.health import Registry
| `kit.clients` | Outbound calls: deadlines, retries with jitter, a circuit breaker, trace and request-id propagation, pluggable auth |
| `kit.config` | Env loading, the service prefix, the `PORT` and `OTEL_*` exceptions, fail-fast process settings and fail-soft backing systems |
| `kit.email` | Outbound mail: the message every provider agrees on, and one Resend adapter on top of `kit.clients` |
| `kit.messaging` | The event bus: publish and pull-subscribe over NATS JetStream, explicit ack, nak and term, and the four headers tenancy and idempotency travel in |
| `kit.testing` | The contract tests a service inherits, so the shared behaviour is verified in every repository |

About a thousand lines. The most important piece is also the smallest: roughly
Expand Down Expand Up @@ -95,6 +96,20 @@ honest is what it refuses to hold. Templates, rendered copy and which person
receives which message stay in the service, because those are the parts that
differ, and a package holding them would be a mail product rather than a seam.

`kit.messaging` is admitted on the same argument and is worth checking against
it just as hard, because it arrived on its FIRST copy rather than its third. It
is not an abstraction over a domain: it is the ack policy, the redelivery
behaviour, the reconnection settings and the header convention, and those go
wrong the same way in every service that decides them alone. They also go wrong
invisibly, and are found during an incident, with a consumer that has been
quietly reprocessing or quietly skipping.

The boundary that keeps it honest is again what it refuses to hold. The payload
is opaque bytes: there is no schema, no event registry and no versioning, because
what an event IS belongs to the services that agree on it. It creates no streams
and no consumers either, since retention and replica counts are operational
decisions, and a library that makes them makes them once, wrongly, in production.

Business models, ORM models, migrations, route trees and service settings never
belong here at all.

Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

[project]
name = "scadable-kit"
version = "0.6.1"
version = "0.7.0"
description = "Cross-cutting behaviour shared by every SCADABLE service"
requires-python = ">=3.14,<3.15"
license = { file = "LICENSE" }
Expand Down Expand Up @@ -45,6 +45,12 @@ otel = [
# traceparent, B starts a new trace, and one request is two unlinked traces.
"opentelemetry-instrumentation-httpx>=0.65b0",
]
# The bus is an extra for the same reason telemetry is: most services never
# touch it, and a dependency here is one every service in the fleet inherits and
# cannot refuse. `kit.messaging` imports cleanly without it, and raises a
# BrokerUnavailable naming this extra if something actually tries to connect,
# which is a failure a service that forgot can read.
nats = ["nats-py>=2.15,<3"]

[build-system]
requires = ["hatchling"]
Expand Down
2 changes: 1 addition & 1 deletion src/kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
across the fleet without opening every repository.
"""

__version__ = "0.6.1"
__version__ = "0.7.0"
88 changes: 88 additions & 0 deletions src/kit/messaging/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""The event bus, as transport policy the whole fleet shares.

bus = MessageBus(Broker(name="events", servers=("nats://nats.nats:4222",)))
await bus.connect()

await bus.publish(
"events.repository.indexed",
payload,
Envelope(tenant=tenant, event_id=uid, event_type="repository.indexed",
occurred_at=now),
)

subscription = await bus.subscribe(stream="EVENTS", subject="events.>",
durable="brain")
for message in await subscription.fetch(batch=8):
await handle(message)
await message.ack()

WHY THIS IS IN THE KIT AT ALL, given that "code moves in on its third copy". This
is the `kit.clients` case rather than the database-helpers case: it is not an
abstraction over a domain, it is the ack policy, the redelivery behaviour, the
reconnection settings and the header convention, and those are wrong in the same
way in every service that decides them alone. They are also wrong in a way that
only shows up during an incident, when a consumer has been quietly reprocessing
or quietly skipping.

THE BOUNDARY THAT KEEPS IT HONEST IS WHAT IT REFUSES TO HOLD. The payload is
opaque bytes. There is no schema, no event registry and no versioning: what an
event IS belongs to the services that agree on it, and a package holding that
would be a messaging product rather than a seam. It creates no streams and no
consumers either, because retention and replica counts are operational decisions
and a library that makes them makes them once, wrongly, in production.

WHAT IS POLICY AND WHAT IS NOT. Timeouts, batch sizes, reconnection and nak
delays are parameters and yours to change per broker. The four header names are
not: they are how tenancy and idempotency travel, and a service that renames one
stops being readable by every other service on the bus.

AT LEAST ONCE, SAID PLAINLY. `fetch` returns unsettled messages and settling is
an explicit `ack`, `nak` or `term`. Nothing here acknowledges on your behalf, so
a handler that raises gets its message back rather than losing it. Handlers must
be idempotent; `Envelope.event_id` is what they deduplicate on, and it rides the
wire as `Nats-Msg-Id` so the server suppresses duplicate publishes too.
"""

from __future__ import annotations

from kit.messaging._broker import (
DEFAULT_FETCH_TIMEOUT_SECONDS,
Broker,
Message,
MessageBus,
Subscription,
)
from kit.messaging._errors import (
BrokerError,
BrokerTimeout,
BrokerUnavailable,
MalformedMessage,
)
from kit.messaging._headers import (
EVENT_ID,
EVENT_TYPE,
OCCURRED_AT,
TENANT,
Envelope,
envelope,
)
from kit.messaging._health import register_brokers

__all__ = [
"DEFAULT_FETCH_TIMEOUT_SECONDS",
"EVENT_ID",
"EVENT_TYPE",
"OCCURRED_AT",
"TENANT",
"Broker",
"BrokerError",
"BrokerTimeout",
"BrokerUnavailable",
"Envelope",
"MalformedMessage",
"Message",
"MessageBus",
"Subscription",
"envelope",
"register_brokers",
]
Loading
Loading