API reference¶
Generated from the source docstrings.
Note
Only the public surface is documented here. jasil._core is internal — see
API stability.
Providers¶
jasil.providers ¶
Platform providers — the tiny interfaces domain code depends on.
Pure module: only stdlib typing and the event envelope. No infrastructure
(redis / boto3 / sqlalchemy) and no domain imports, so any module can depend on
these providers without pulling in a backend. Concrete backends live in
jasil.backends and are selected by the composition root
(jasil.container.build_platform).
ClockProvider ¶
Bases: Protocol
Injectable time source for testability.
Source code in jasil/providers.py
149 150 151 152 153 154 | |
EventBusProvider ¶
Bases: Protocol
Publish/subscribe — synchronous in-process, or durable via Redis Streams.
Source code in jasil/providers.py
104 105 106 107 108 109 110 111 | |
EventRecorder ¶
Bases: Protocol
Records each event's lifecycle to durable storage for observability.
Injected into the event bus by the composition root when event logging is
enabled. Recording must never disrupt event processing, so implementations
swallow and log their own storage errors. :meth:record_published and
:meth:record_queued insert the initial row for a bus-delivered or
durable-delivered event respectively. :meth:track wraps handler
execution — it marks the event processing on entry (unless
record_processing is False, for a synchronous single-process dispatch
where the intermediate state is never observed) and completed / failed on
exit, re-raising any handler exception so the bus keeps its own error
semantics (propagate in-process, leave pending on Redis Streams).
Source code in jasil/providers.py
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | |
GeocodedPlace
dataclass
¶
A place resolved from coordinates. Any field may be None.
Attributes:
| Name | Type | Description |
|---|---|---|
city |
str | None
|
Populated place name, when the provider resolved one. |
town |
str | None
|
Sub-locality (district/town), when the provider resolved one. |
country |
str | None
|
Country name, when the provider resolved one. |
Source code in jasil/providers.py
157 158 159 160 161 162 163 164 165 166 167 168 169 | |
GeocodingProvider ¶
Bases: Protocol
Reverse-geocoding — turn a coordinate into a place name.
The one seam through which domain code performs reverse geocoding, so a caller never knows which upstream service is configured, nor that a network call happens at all. That matters more here than for the other providers: this is the platform's only outbound call to a third party, so the egress hardening it needs (host validation, address denylist, no redirects, rate limiting) lives behind this interface instead of in a domain module.
Implementations must not raise for an upstream failure — geocoding is
best-effort enrichment, and a provider outage must never fail the import or
backfill that triggered it. Return None when nothing resolves.
Source code in jasil/providers.py
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | |
LockProvider ¶
Bases: Protocol
Best-effort mutual exclusion for scheduled/backfill work.
Source code in jasil/providers.py
142 143 144 145 146 | |
StateBackendUnavailableError ¶
Bases: RuntimeError
Raised by a :class:StateProvider when its backing store is unreachable.
Lets domain stores react to an infrastructure outage (e.g. surface a 503 or swallow a best-effort cleanup) without knowing or importing anything about the concrete backend (Redis). The in-memory backend never raises it.
Source code in jasil/providers.py
19 20 21 22 23 24 25 | |
StateProvider ¶
Bases: Protocol
Ephemeral keyed state (counters, TTL flags, small blobs).
The single seam through which domain code reads and writes short-lived
shared state, so a store never needs to know whether it is backed by a
process-local dict (local) or Redis (distributed). Beyond plain
key/value access it exposes the few atomic primitives that login-throttling
and single-use-token stores need (set_if_absent, get_and_delete,
record_tiered_failure) so their correctness does not depend on the
backend.
Source code in jasil/providers.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | |
StorageProvider ¶
Bases: Protocol
Opaque byte-blob storage addressed by a key within a named area.
An area is a domain-owned namespace (e.g. "avatars", "exports") so
one backend serves every subsystem: locally it maps to a subdirectory, on S3
to a key prefix. The database stores only the key (the area is a fixed
constant of the calling domain); url is computed at serialization time so
migrating local -> S3 needs no data migration.
Most subsystems only ever write a blob and later serve it via url, but
some need the bytes back in-process (e.g. bundling stored files into an
export); get is that read path, returning None when the blob is
absent.
list_keys exists for the subsystems whose keys are not derivable from a
domain id — for instance when a key carries a random component. Without a
prefix listing, such a subsystem would have to reach past the provider to the
filesystem to clean up after a deleted record, which is exactly what this
provider exists to prevent.
Source code in jasil/providers.py
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
TieredFailureOutcome
dataclass
¶
Result of an atomic tiered-lockout increment.
Attributes:
| Name | Type | Description |
|---|---|---|
count |
int
|
The failure counter value after this attempt. |
locked_until_epoch |
int | None
|
Wall-clock epoch (seconds) the lock is active until,
or |
newly_locked |
bool
|
True only when this call created (or renewed) the lock. |
Source code in jasil/providers.py
28 29 30 31 32 33 34 35 36 37 38 39 40 41 | |
Events¶
jasil.events ¶
The event envelope, new_event helper, and standard metadata keys.
Pure module — the single structured shape every event travels in, so the pipeline can route, trace, correlate, dedup, and retry without knowing the domain.
Channel names (event_type values) are owned by the domain that publishes
them, not defined here — e.g. the orders module owns order.created.
Keeping them out of the substrate stops this generic layer from accumulating
domain knowledge; a producer and its subscribers import the same domain-side
constant so they cannot drift on the string. Convention: <domain>.<fact> in
past tense. event_type stays a plain str on the envelope so the bus is
open to new events with no edits here.
The envelope is defined ahead of the bus so the wire format never has to change once the first producer ships.
Payload versioning¶
schema_version describes the shape of payload for its event_type. It
lives on the envelope rather than inside each payload dict so the substrate can
carry it without parsing domain data, and so no producer can forget it.
It exists because the code that writes a payload and the code that reads it
are not guaranteed to be the same version: a durable event is staged in the
outbox, relayed on a schedule, retried with backoff, and may sit dead-lettered
indefinitely — and during a rolling deploy old and new replicas run at once. A
consumer that silently ignores unknown keys (every payload model sets
extra="ignore") would read a renamed or repurposed field as its default and
do the wrong thing quietly. The version turns that into something a consumer can
detect: see :mod:jasil.event_versioning.
The number is owned by the publishing domain, like the channel name. Bump it in the domain's publisher when the payload's shape or a field's meaning changes; purely additive optional fields do not need a bump.
Event
dataclass
¶
Envelope wrapping every event in the system.
Attributes:
| Name | Type | Description |
|---|---|---|
event_id |
str
|
UUIDv4 identifying this event instance; stable across retries. |
event_type |
str
|
Dot-notation channel, e.g. |
source |
str
|
Where the event originated, e.g. |
timestamp |
str
|
ISO-8601 UTC timestamp of the first publish (not the retry). |
payload |
dict
|
Domain data, homogeneous per |
metadata |
dict
|
Correlation context (request_id, plus any host-defined keys). |
retry_count |
int
|
Processing attempts so far; 0 on first publish. |
schema_version |
int
|
Which version of |
Source code in jasil/events.py
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | |
new_event ¶
new_event(
event_type,
payload,
*,
source,
metadata=None,
event_id=None,
retry_count=0,
schema_version=INITIAL_SCHEMA_VERSION,
)
Mint an :class:Event, generating event_id and timestamp.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The channel/type, e.g. |
required |
payload
|
dict
|
Domain data for the event. |
required |
source
|
str
|
Origin label, e.g. |
required |
metadata
|
dict | None
|
Optional correlation context. |
None
|
event_id
|
str | None
|
Optional explicit id (defaults to a fresh UUIDv4); reuse the original id when re-publishing a retry so tracing stays stable. |
None
|
retry_count
|
int
|
Attempt counter (incremented on re-publish). |
0
|
schema_version
|
int
|
The payload-shape version this producer writes. |
INITIAL_SCHEMA_VERSION
|
Returns:
| Type | Description |
|---|---|
Event
|
A frozen :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/events.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | |
Payload versioning¶
jasil.event_versioning ¶
Version-aware payload parsing for durable and bus subscribers.
A subscriber never calls Model.model_validate(event.payload) directly; it
goes through :func:parse_payload, which compares the envelope's
schema_version against the version the local code understands and does the
right thing for each direction of skew.
Why the direction matters¶
The writer and the reader of an event are frequently not the same build: an event is staged in the outbox, relayed on a schedule, retried with exponential backoff, and can sit dead-lettered indefinitely — and during a rolling deploy old and new replicas serve traffic simultaneously.
- Older event, newer consumer (the common case — the outbox backlog after a
deploy). This must actually work, so the payload model registers an explicit
upgrader per version step. Silently defaulting a missing field is exactly the
failure mode this module exists to prevent: every payload model sets
extra="ignore", so a renamed or repurposed field would otherwise be dropped and read as its default, producing a wrong result with no error anywhere. - Newer event, older consumer (a new replica publishes before the old workers
drain). Nothing sensible can be done — the local code has never seen this
shape. :class:
UnsupportedEventVersionErroris raised so the existing job machinery retries with backoff; the condition is self-healing, because by the time the attempts are spent the stale worker is normally gone. Failing loudly and retrying is strictly better than guessing.
No new machinery is needed for either: durable handlers already raise on failure and the runner already retries and then dead-letters.
UnsupportedEventVersionError ¶
Bases: Exception
Raised when an event's payload version is newer than this build understands.
Deliberately an ordinary exception rather than a domain error: it is never seen by an HTTP caller, only by the durable-job runner (which retries, then dead-letters) or the best-effort bus wrapper (which logs and swallows).
Source code in jasil/event_versioning.py
43 44 45 46 47 48 49 | |
VersionedPayload ¶
Bases: BaseModel
Base for every event payload model, carrying its own schema version.
Subclasses set :attr:SCHEMA_VERSION and, once they have evolved past
version 1, register an upgrader per version step in :attr:UPGRADERS.
Both are ClassVar — without that annotation Pydantic would treat them as
model fields, so they would be expected in every payload dict and would not
be readable off the class at all.
Attributes:
| Name | Type | Description |
|---|---|---|
SCHEMA_VERSION |
int
|
The payload shape this build reads and writes. |
UPGRADERS |
dict[int, Callable[[dict], dict]]
|
|
Source code in jasil/event_versioning.py
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | |
parse_payload ¶
parse_payload(model, event)
Validate an event's payload, upgrading it from an older version if needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
type[T]
|
The payload model this subscriber consumes. |
required |
event
|
Event
|
The event envelope, carrying |
required |
Returns:
| Type | Description |
|---|---|
T
|
The validated payload at the local build's schema version. |
Raises:
| Type | Description |
|---|---|
UnsupportedEventVersionError
|
When the event was written by a newer build than this one. |
ValidationError
|
When the payload does not match its declared version. |
Source code in jasil/event_versioning.py
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | |
Publishing¶
jasil.publisher ¶
The single publish seam every producer goes through.
One tiny facade so no producer ever assembles an :class:~jasil.events.Event
or touches the active platform directly. Centralising publishing here means the
transactional outbox is a change to this function alone,
not to every call site.
It resolves the active platform, stamps the ambient request id for correlation, and mints the envelope. Delivery then takes one of two routes:
- Durable (outbox): when durable jobs are enabled, the caller supplies its DB
session, and the event type has registered durable subscribers, the event is
written to the
event_outbox— the relay later fans it out into retryable per-subscriber jobs. The event is also recordedqueuedinevent_logso the observability dashboard reflects durable events too (execution detail then lives in the Jobs dashboard). - Best-effort (bus): otherwise the event is dispatched through the event bus
(inline in
local, via Redis Streams indistributed), which records the full lifecycle itself.
Delivery guarantee. The producer's domain row is the source of truth; this publish is best-effort from the producer's perspective — failures are logged and swallowed so publishing never breaks the producer's own work. The outbox is not committed in the same transaction as the domain change (the ingestion path commits per-CRUD), so a crash between the domain commit and the outbox write can drop an event. Every subscriber must therefore have a reconciliation net — a backfill or sweeper that re-derives missed work). A future unit-of-work refactor can upgrade this to a genuinely atomic outbox; until then, "durable" means retryable once written, not never lost. Channel names and payload shape stay owned by the publishing domain; this layer only knows the generic envelope.
publish ¶
publish(
event_type,
payload,
*,
source,
metadata=None,
db=None,
schema_version=INITIAL_SCHEMA_VERSION,
)
Publish a domain event through the active platform, best-effort.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The domain-owned channel, e.g. |
required |
payload
|
dict
|
Domain data for the event (homogeneous per |
required |
source
|
str
|
Origin label, e.g. |
required |
metadata
|
dict | None
|
Optional correlation context; merged with the ambient request id when one is set on the current request. |
None
|
db
|
Any
|
The producer's SQLAlchemy session. When provided and durable jobs are enabled for this event type, the event is written to the outbox using this session (durable delivery); otherwise it is ignored. |
None
|
Returns:
| Type | Description |
|---|---|
None
|
None. Delivery failures are logged and swallowed so a publish never |
None
|
breaks the producer. The domain row remains the source of truth and each |
None
|
subscriber's reconciliation net recovers anything missed. |
Source code in jasil/publisher.py
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | |
publish_committing ¶
publish_committing(
event_type,
payload,
*,
source,
metadata=None,
db,
commit,
schema_version=INITIAL_SCHEMA_VERSION,
)
Publish a domain event atomically around the caller's domain commit.
Unlike :func:publish (which the caller invokes after it has already
committed its own change), this variant owns the commit ordering so durable
delivery can be made atomic with the domain write. commit is a zero-arg
callable that commits the caller's unit of work.
- Durable delivery enabled (durable jobs on + a durable subscriber for
event_type): the outbox row is staged ondbwithout committing, thencommit()flushes the domain rows and the outbox row in one transaction — so the event can never be lost relative to the change that produced it. A staging failure propagates (the caller's transaction is left uncommitted for rollback), so the whole unit of work is all-or-nothing. - Otherwise (best-effort bus path):
commit()runs first so the domain row — the source of truth — is durable regardless, then the event is dispatched on the bus and any dispatch failure is logged and swallowed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The domain-owned channel, e.g. |
required |
payload
|
dict
|
Domain data for the event. |
required |
source
|
str
|
Origin label, e.g. |
required |
metadata
|
dict | None
|
Optional correlation context; merged with the ambient request id. |
None
|
db
|
Any
|
The producer's SQLAlchemy session (holds the uncommitted domain change). |
required |
commit
|
Callable[[], None]
|
Zero-arg callable that commits the caller's unit of work. |
required |
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/publisher.py
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | |
publish_many_committing ¶
publish_many_committing(
event_type,
payloads,
*,
source,
metadata_for=None,
db,
commit,
schema_version=INITIAL_SCHEMA_VERSION,
)
Publish many same-type events atomically around one domain commit.
The batch counterpart of :func:publish_committing, for producers that
remove or create many rows in a single unit of work (bulk deletes) and must
emit one event per affected row without committing once per event. All outbox
rows are staged on db uncommitted and land in the caller's transaction, so
the domain change and every event commit together or not at all.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The domain-owned channel shared by every event in the batch. |
required |
payloads
|
Sequence[dict]
|
One payload per event. An empty sequence still runs |
required |
source
|
str
|
Origin label, e.g. |
required |
metadata_for
|
Callable[[dict], dict | None] | None
|
Optional per-payload correlation metadata builder. |
None
|
db
|
Any
|
The producer's SQLAlchemy session (holds the uncommitted change). |
required |
commit
|
Callable[[], None]
|
Zero-arg callable that commits the caller's unit of work. |
required |
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/publisher.py
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | |
Subscribers¶
jasil.subscribers ¶
Helpers for writing event-bus subscribers.
Every domain subscriber in the codebase is written twice: a raising core (the
durable-job handler, so the runner can retry and eventually dead-letter) and a
swallowing wrapper (the bus subscriber, so a derived-work failure never breaks
the producing request). :func:best_effort is that wrapper, defined once here
instead of being copy-pasted per subsystem — which previously meant each copy
could drift in what it logged and which exceptions it caught.
best_effort ¶
best_effort(handler)
Wrap a raising event handler into a swallowing bus subscriber.
The returned subscriber logs and absorbs any exception, so derived work can never fail the request or consumer that produced the event. The wrapped handler stays available for durable-job registration, where failures must propagate to drive retry and dead-lettering.
Usage::
def do_work_for_event(event: Event) -> None: ...
on_event_do_work = best_effort(do_work_for_event)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
handler
|
Callable[[Event], None]
|
The raising handler to wrap. |
required |
Returns:
| Type | Description |
|---|---|
Callable[[Event], None]
|
A subscriber with the same signature that never raises. |
Source code in jasil/subscribers.py
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | |
Deployment profile¶
jasil.profile ¶
Deployment profile and capability-topology resolution.
Pure module — no I/O and no infrastructure imports. It answers two questions the rest of the platform substrate builds on:
- How is this deployment shaped?
DeploymentProfile(local/distributed/custom) plus the configured number of web workers. - Does that shape require state shared across processes?
DeploymentTopology.requires_shared_state.
It also classifies a storage URI as memory- or Redis-backed so callers can
check the effective wiring against the required shape. Keeping this logic free
of infrastructure imports is what lets :mod:jasil.settings and
:mod:jasil.capabilities build on it without an import cycle.
DeploymentProfile ¶
Bases: StrEnum
How a deployment is shaped.
Attributes:
| Name | Type | Description |
|---|---|---|
LOCAL |
Single process/node — in-memory state, local disk, in-process events. The default; requires no extra infrastructure. |
|
DISTRIBUTED |
Multi-node — requires shared state (Redis), object storage, and cross-process coordination. |
|
CUSTOM |
No profile defaults; every capability must be set explicitly. |
Source code in jasil/profile.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 | |
DeploymentTopology
dataclass
¶
Resolved deployment shape.
Attributes:
| Name | Type | Description |
|---|---|---|
profile |
DeploymentProfile
|
The deployment profile. |
web_workers |
int
|
Number of web-server worker processes (always >= 1). |
Source code in jasil/profile.py
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | |
requires_shared_state
property
¶
requires_shared_state
Whether ephemeral state must be shared across processes.
True for the distributed profile or any multi-worker deployment,
because process-local memory cannot be shared between workers/replicas.
StateBackendKind ¶
Bases: StrEnum
Classification of a state storage URI.
Attributes:
| Name | Type | Description |
|---|---|---|
MEMORY |
Process-local memory ( |
|
REDIS |
Redis-backed ( |
|
UNKNOWN |
Unrecognized or unset scheme. |
Source code in jasil/profile.py
37 38 39 40 41 42 43 44 45 46 47 48 | |
classify_state_uri ¶
classify_state_uri(storage_uri)
Classify a state storage URI as memory / redis / unknown.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
storage_uri
|
str | None
|
A storage URI (e.g. |
required |
Returns:
| Type | Description |
|---|---|
StateBackendKind
|
The matching |
Source code in jasil/profile.py
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | |
parse_profile ¶
parse_profile(value)
Parse a raw profile value into a DeploymentProfile.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str | DeploymentProfile | None
|
Raw environment value, an existing profile, or |
required |
Returns:
| Type | Description |
|---|---|
DeploymentProfile
|
The parsed profile; |
Raises:
| Type | Description |
|---|---|
ValueError
|
When the value is a non-empty, unrecognized profile name.
Raising (rather than defaulting) prevents a typo like
|
Source code in jasil/profile.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | |
resolve_topology ¶
resolve_topology(profile, web_workers)
Resolve the deployment topology, clamping web_workers to >= 1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
profile
|
DeploymentProfile
|
The deployment profile. |
required |
web_workers
|
int
|
Configured worker count. |
required |
Returns:
| Type | Description |
|---|---|
DeploymentTopology
|
The resolved |
Source code in jasil/profile.py
125 126 127 128 129 130 131 132 133 134 135 | |
Capability report¶
jasil.capabilities ¶
Startup capability report and deployment-consistency checks.
Renders how each infrastructure capability (state, storage, events, lock, clock) is wired for a human-readable startup log, and detects fatal inconsistencies — a deployment that requires a shared backend but resolves one to a process- or node-local implementation.
Three consistency rules are enforced:
- Cross-process backends — ephemeral state (rate limiting, throttling,
single-use tokens) and the event bus — must not resolve to process-local
memory when the topology requires shared state (the
distributedprofile or more than one web worker); the stores and the bus would diverge silently across processes. Both share thememory:///redis://vocabulary and are validated by :func:check_state_consistency. - Cross-node storage must not resolve to the local filesystem under the
distributedprofile, where replicas run on separate nodes with no shared disk. A multi-worker local deployment shares one host disk, so local storage stays valid there. Validated by :func:check_storage_consistency. - The coordination lock must not resolve to the in-process
nooplock whenever the deployment runs more than one process (thedistributedprofile or any multi-worker deployment), or every process would run every scheduled job. Validated by :func:check_lock_consistency.
The clock is always the system clock, so its default is never a fatal choice here.
:func:check_deployment_consistency applies all three to a
:class:~jasil.settings.JasilSettings, and jasil.container.build_platform
calls it at startup: an inconsistency raises unless the host sets
enforce_deployment_consistency=False, which downgrades it to a warning.
Pure module — no infrastructure imports, so every check runs before a single backend has been constructed.
CapabilityReport
dataclass
¶
A rendered snapshot of how each capability is wired at startup.
Source code in jasil/capabilities.py
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
render ¶
render()
Render the report as a multi-line, aligned string.
Source code in jasil/capabilities.py
96 97 98 99 100 101 102 103 104 105 | |
CapabilityRow
dataclass
¶
One line of the capability report.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Capability name (state / storage / events / lock / clock). |
backend |
str
|
The resolved backend (e.g. |
source |
str
|
Where the value came from (setting name or |
Source code in jasil/capabilities.py
74 75 76 77 78 79 80 81 82 83 84 85 86 | |
StateSource
dataclass
¶
A configured source of ephemeral state.
Attributes:
| Name | Type | Description |
|---|---|---|
label |
str
|
The setting backing this state (e.g. |
uri |
str
|
The effective storage URI. |
applies |
bool
|
Whether this source is active — a capability the host disabled cannot be misconfigured. |
Source code in jasil/capabilities.py
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | |
build_capability_report ¶
build_capability_report(settings)
Build the observational capability report for startup logging.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
JasilSettings
|
The configuration the platform is being built from. |
required |
Returns:
| Type | Description |
|---|---|
CapabilityReport
|
A |
CapabilityReport
|
capability resolved to, and whether that came from an explicit setting or |
CapabilityReport
|
from the deployment profile's default. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When a capability URI is unset under a profile that has no default for it. |
Source code in jasil/capabilities.py
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | |
check_deployment_consistency ¶
check_deployment_consistency(settings)
Return every fatal wiring inconsistency in settings (empty when sound).
The entry point build_platform uses: it resolves the capability URIs the
profile would actually build from, then applies all three rules to them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
JasilSettings
|
The configuration the platform is being built from. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
Human-readable issue messages; empty when the wiring is consistent. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When a capability URI is unset under a profile that has no default for it. |
Source code in jasil/capabilities.py
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | |
check_lock_consistency ¶
check_lock_consistency(
*, profile, web_workers, lock_uri, lock_label
)
Return a fatal issue when a multi-process deployment uses a no-op lock.
The coordination lock makes scheduled and backfill work single-runner across
processes. Whenever a deployment runs more than one process — the
distributed profile or any multi-worker deployment
(:attr:~jasil.profile.DeploymentTopology.requires_shared_state) — an
in-process noop:// lock coordinates nothing, so every process would run
every interval job (the retention prune, a backfill, an upstream sync). The
profile-aware default already resolves to postgres-advisory:// in that
case, so this only trips on an explicit lock_uri="noop://" override. A
single-process local deployment has nothing to coordinate, so noop
stays valid there.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
profile
|
DeploymentProfile
|
The deployment profile. |
required |
web_workers
|
int
|
Configured worker count. |
required |
lock_uri
|
str
|
The resolved coordination-lock URI. |
required |
lock_label
|
str
|
The setting backing the lock (for the message). |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
A single-item issue list when misconfigured; empty otherwise. The |
list[str]
|
|
Source code in jasil/capabilities.py
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | |
check_state_consistency ¶
check_state_consistency(
*, profile, web_workers, state_sources
)
Return fatal issues for cross-process backends (empty when consistent).
A deployment that requires shared state (the distributed profile or more
than one web worker) but resolves a cross-process backend to process-local
memory is fatally misconfigured: ephemeral state (rate-limit counters, lockout
gates, single-use tokens) and the in-process event bus would silently diverge
across processes. State stores and the event bus share the memory:// /
redis:// vocabulary, so both are validated here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
profile
|
DeploymentProfile
|
The deployment profile. |
required |
web_workers
|
int
|
Configured worker count. |
required |
state_sources
|
Sequence[StateSource]
|
The active cross-process backends to validate (the resolved state and event-bus URIs). |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
Human-readable issue messages; empty when consistent. The |
list[str]
|
profile is exempt — it promises no defaults, so nothing can contradict one. |
Source code in jasil/capabilities.py
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | |
check_storage_consistency ¶
check_storage_consistency(
*, profile, storage_uri, storage_label
)
Return a fatal issue when distributed storage resolves to local disk.
Under the distributed profile replicas run on separate nodes that do not
share a filesystem, so blob storage must be object storage. A multi-worker
local deployment shares one host disk, so local storage stays valid there
and is not flagged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
profile
|
DeploymentProfile
|
The deployment profile. |
required |
storage_uri
|
str
|
The resolved blob-storage URI. |
required |
storage_label
|
str
|
The setting backing blob storage (for the message). |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
A single-item issue list when misconfigured; empty otherwise. |
Source code in jasil/capabilities.py
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | |
Settings¶
jasil.settings ¶
Host-supplied configuration for JASIL.
JASIL never reads environment variables or secret files itself. The host builds a
:class:JasilSettings from whatever configuration source it likes and installs
it once at startup::
import jasil.settings as jasil_settings
jasil_settings.configure(
jasil_settings.JasilSettings(
profile=jasil.DeploymentProfile.DISTRIBUTED,
state_uri="redis://cache:6379/0",
storage_uri="s3://bucket",
events_uri="redis://cache:6379/1",
)
)
Every component reads the installed settings through :func:get_settings.
Shape. Configuration is grouped by concern — :class:JobSettings,
:class:EventLogSettings, :class:GeocodingSettings, :class:NetworkSettings —
rather than one flat list, so a host enabling durable jobs reads one small class.
Only the values describing the deployment shape sit at the top level.
Profile defaults. The four capability URIs may be left unset, in which case
the deployment profile supplies the default: local resolves to the
single-process backends, while distributed and custom refuse to guess and
raise, because a Redis host or bucket name cannot be inferred.
EventLogSettings
dataclass
¶
Event-observability trail configuration.
Attributes:
| Name | Type | Description |
|---|---|---|
enabled |
bool
|
Record every event's lifecycle to the |
retention_days |
int
|
Age at which trail rows are pruned. |
Source code in jasil/settings.py
77 78 79 80 81 82 83 84 85 86 87 88 | |
GeocodingSettings
dataclass
¶
Reverse-geocoding backend configuration.
Attributes:
| Name | Type | Description |
|---|---|---|
provider |
str
|
|
rate_limit |
float
|
Maximum requests per second; |
api_key |
str | None
|
API key, for the services requiring one (geocode.maps.co). |
nominatim_host |
str
|
Bare |
nominatim_use_https |
bool
|
Address Nominatim over HTTPS. |
photon_host |
str
|
Bare |
photon_use_https |
bool
|
Address Photon over HTTPS. |
user_agent |
str
|
|
Source code in jasil/settings.py
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
JasilSettings
dataclass
¶
The full JASIL configuration.
Attributes:
| Name | Type | Description |
|---|---|---|
profile |
DeploymentProfile
|
The deployment shape; supplies the capability-URI defaults. |
web_workers |
int
|
How many web-server worker processes the host runs. Only the
count matters: four workers under the |
enforce_deployment_consistency |
bool
|
Refuse to build a platform whose wiring
contradicts its topology (see :mod: |
data_dir |
str
|
Root directory for the local storage backend. |
state_uri |
str | None
|
|
storage_uri |
str | None
|
|
events_uri |
str | None
|
|
lock_uri |
str | None
|
|
jobs |
JobSettings
|
Durable-job pipeline configuration. |
event_log |
EventLogSettings
|
Event-observability trail configuration. |
geocoding |
GeocodingSettings
|
Reverse-geocoding backend configuration. |
network |
NetworkSettings
|
Outbound-egress configuration. |
Source code in jasil/settings.py
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | |
JobSettings
dataclass
¶
Durable-job pipeline configuration.
Attributes:
| Name | Type | Description |
|---|---|---|
enabled |
bool
|
Route events through the transactional outbox instead of the event bus. When false the whole jobs layer stays dormant. |
lease_seconds |
int
|
How long a claimed job is leased to a worker before the reaper may reclaim it. |
batch_size |
int
|
Maximum rows claimed or relayed per pass. |
backoff_base_seconds |
int
|
First retry delay; doubles per attempt. |
backoff_max_seconds |
int
|
Ceiling for the exponential backoff. |
poll_interval_seconds |
float
|
Idle wait between empty polls. |
max_attempts |
int
|
Attempts before a job is dead-lettered. |
retention_days |
int
|
Age at which relayed outbox rows and completed jobs are
pruned. |
Source code in jasil/settings.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | |
NetworkSettings
dataclass
¶
Outbound-egress configuration.
Attributes:
| Name | Type | Description |
|---|---|---|
ssrf_allowed_hosts |
tuple[str, ...]
|
Hostnames and CIDRs exempt from the SSRF address denylist, so a self-hosted service on a private network stays reachable. Every use is logged. |
Source code in jasil/settings.py
118 119 120 121 122 123 124 125 126 127 128 | |
configure ¶
configure(settings)
Install the host's settings for the process.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
JasilSettings
|
The configuration to install. |
required |
Source code in jasil/settings.py
221 222 223 224 225 226 227 | |
get_settings ¶
get_settings()
Return the installed settings, or the all-defaults instance.
Source code in jasil/settings.py
230 231 232 | |
is_configured ¶
is_configured()
Return whether :func:configure has been called.
Source code in jasil/settings.py
235 236 237 | |
reset ¶
reset()
Restore the all-defaults settings.
For tests; production code configures once at startup and never resets.
Source code in jasil/settings.py
240 241 242 243 244 245 | |
Correlation¶
jasil.correlation ¶
Correlation-id seam for stamping events with the ambient request id.
Events carry a correlation id so a downstream failure can be traced back to the request that triggered it. Where that id comes from is the host's business: a web framework's request middleware, a task-queue header, or nothing at all.
By default the id lives in a module-local :class:~contextvars.ContextVar that
the host sets with :func:set_correlation_id. A host that already tracks one
(request-id middleware, an OpenTelemetry span) installs a reader instead::
import jasil.correlation as correlation
correlation.configure_provider(my_middleware.get_request_id)
Both paths are optional; with neither configured, events simply carry no correlation id.
configure_provider ¶
configure_provider(provider)
Install a host callable that returns the current correlation id.
Call once at startup. Passing None restores the built-in contextvar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
provider
|
Callable[[], str | None] | None
|
Returns the ambient correlation id, or |
required |
Source code in jasil/correlation.py
33 34 35 36 37 38 39 40 41 42 43 | |
get_correlation_id ¶
get_correlation_id()
Return the ambient correlation id, or None.
Never raises: a provider that fails is treated as "no id", because a correlation id is diagnostic metadata and must not break publishing.
Source code in jasil/correlation.py
57 58 59 60 61 62 63 64 65 66 67 68 | |
reset ¶
reset()
Clear the installed provider and the current context's id.
Source code in jasil/correlation.py
71 72 73 74 75 | |
set_correlation_id ¶
set_correlation_id(value)
Set the correlation id for the current context.
Only consulted while no provider is installed via :func:configure_provider.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str | None
|
The id to stamp on events minted in this context. |
required |
Source code in jasil/correlation.py
46 47 48 49 50 51 52 53 54 | |
ORM integration¶
jasil.orm ¶
JASIL's SQLAlchemy registry plumbing and session-factory helpers.
Option B (the host owns the Base). JASIL's companion tables —
event_log, processing_jobs, event_outbox — must live in the same
declarative registry as the host's own models so that one create_all /
migration run and one metadata object cover the whole schema. The host owns that
registry; JASIL maps its models into it.
Host applications:
-
Own a declarative base::
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): ... # the host's own base (naming conventions, schema, ...)
-
Map JASIL's tables into that base's registry, once, at startup::
import jasil.orm as jasil_orm jasil_orm.map_models(Base)
This must happen before any JASIL database use — importing a JASIL model
module (or a CRUD module that imports one) beforehand is a configuration
error. A host that would rather not own a base may call map_models() with
no argument and use JASIL's convenience :data:Base.
-
Register a session factory bound to their own engine::
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker
engine = create_engine(...) jasil_orm.configure_sessionmaker(sessionmaker(bind=engine))
JASIL never creates the engine; the host owns the connection.
Base ¶
Bases: DeclarativeBase
JASIL's convenience declarative base.
Under Option B the host owns the registry: define your own
:class:~sqlalchemy.orm.DeclarativeBase and pass it to :func:map_models.
Use this default only if you would rather not own one.
Source code in jasil/orm.py
61 62 63 64 65 66 67 | |
configure_sessionmaker ¶
configure_sessionmaker(factory)
Install the host's session factory.
Call once at startup with a sessionmaker bound to the application's
engine. The event-log recorder, the job runner, the relay, and the retention
sweeps all obtain their sessions from it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factory
|
sessionmaker[Session]
|
A configured |
required |
Source code in jasil/orm.py
156 157 158 159 160 161 162 163 164 165 166 | |
get_active_base ¶
get_active_base()
Return the declarative base JASIL's models are mapped onto.
Model modules call this at import time to obtain their base, so importing a
model module before :func:map_models is a configuration error.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :func: |
Source code in jasil/orm.py
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
get_engine ¶
get_engine()
Return the engine the session factory is bound to.
Needed by the Postgres advisory-lock backend, which holds a dedicated connection for the lifetime of a lock and so cannot work through a session.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :func: |
Source code in jasil/orm.py
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | |
get_sessionmaker ¶
get_sessionmaker()
Return the installed session factory.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :func: |
Source code in jasil/orm.py
169 170 171 172 173 174 175 | |
is_models_mapped ¶
is_models_mapped()
Return whether :func:map_models has been called.
Source code in jasil/orm.py
113 114 115 | |
is_sessionmaker_configured ¶
is_sessionmaker_configured()
Return whether :func:configure_sessionmaker has been called.
Source code in jasil/orm.py
178 179 180 | |
jasil_table_names ¶
jasil_table_names()
Return the names of the tables JASIL owns.
Source code in jasil/orm.py
91 92 93 | |
map_models ¶
map_models(base=None)
Define and map JASIL's companion tables into base's registry.
Call once at startup, before any database use. Calling it again with the same base is a no-op, so a host with several entry points need not coordinate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base
|
type[DeclarativeBase] | None
|
The host's :class: |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If called again with a different base. |
Source code in jasil/orm.py
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
reset ¶
reset()
Clear the mapped base and the session factory.
For tests that need a clean process-wide state between cases; production code configures once at startup and never resets.
Source code in jasil/orm.py
203 204 205 206 207 208 209 210 211 | |
Composition root¶
jasil.container ¶
The composition root: build the platform substrate from settings.
build_platform resolves each capability (state, storage, events, lock,
clock) to a concrete backend based on the deployment profile and returns a
frozen Platform holding the providers. It is called once at startup and
published process-wide via jasil.runtime so both request and non-request
code resolve the same instance.
Every capability resolves its backend by URI scheme, independently of the
profile: memory/redis for the state URI; local/s3 for the storage
URI; memory/redis for the events URI; noop/postgres-advisory for
the lock URI. The deployment profile only shapes the defaults those URIs
resolve to (see the resolved_* properties on
:class:~jasil.settings.JasilSettings), so local, distributed, and
custom all build the same way — the profile just picks memory-vs-Redis and
local-fs-vs-S3 defaults.
Before anything is constructed, the resolved wiring is checked against the
deployment topology (see :mod:jasil.capabilities): a combination that would
silently diverge across processes or nodes stops the build rather than becoming a
production mystery.
Platform
dataclass
¶
The assembled platform substrate — one instance per process.
Attributes:
| Name | Type | Description |
|---|---|---|
profile |
DeploymentProfile
|
The active deployment profile. |
state |
StateProvider
|
Ephemeral keyed-state provider. |
storage |
StorageProvider
|
Blob-storage provider. |
events |
EventBusProvider
|
Publish/subscribe provider. |
lock |
LockProvider
|
Coordination-lock provider. |
clock |
ClockProvider
|
Time-source provider. |
geocoding |
GeocodingProvider
|
Reverse-geocoding provider. Always present — when geocoding is unconfigured or misconfigured this is a no-op backend, so callers never branch on whether the capability exists. |
recorder |
EventRecorder | None
|
Event-log recorder, or |
Source code in jasil/container.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | |
build_platform ¶
build_platform(settings=None)
Assemble the Platform for the configured deployment profile.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
JasilSettings | None
|
The configuration to build from. Defaults to the settings the
host installed via |
None
|
Returns:
| Type | Description |
|---|---|
Platform
|
A frozen |
Raises:
| Type | Description |
|---|---|
ValueError
|
When a capability URI uses an unsupported scheme, is unset under a profile that has no default for it, or when the resolved wiring contradicts the deployment topology. |
RuntimeError
|
When a selected Redis backend cannot be reached. |
Source code in jasil/container.py
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | |
Runtime handle¶
jasil.runtime ¶
Process-wide access to the assembled Platform.
The composition root publishes the assembled Platform here at startup via
:func:set_active_platform, and every caller — request handlers, the scheduler,
the durable-job worker, a background thread — resolves the one instance
through :func:get_active_platform (or :func:get_state). Stores that must work
in any context resolve their provider lazily through :func:get_state.
get_active_platform ¶
get_active_platform()
Return the process-wide platform, or fail if startup has not run.
Returns:
| Type | Description |
|---|---|
Platform
|
The active platform substrate. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When no platform has been published yet. |
Source code in jasil/runtime.py
31 32 33 34 35 36 37 38 39 40 41 42 | |
get_state ¶
get_state()
Return the process-wide ephemeral-state provider.
Returns:
| Type | Description |
|---|---|
StateProvider
|
The active |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When no platform has been published yet. |
Source code in jasil/runtime.py
45 46 47 48 49 50 51 52 53 54 | |
set_active_platform ¶
set_active_platform(platform)
Publish the assembled platform for process-wide access.
Called once from lifespan startup after build_platform.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
platform
|
Platform
|
The assembled platform substrate. |
required |
Source code in jasil/runtime.py
19 20 21 22 23 24 25 26 27 28 | |
FastAPI dependencies¶
jasil.deps ¶
FastAPI dependencies exposing the platform providers to routes and handlers.
The composition root attaches the assembled Platform to
app.state.platform at startup; these thin dependencies read it back so
routes depend on providers (via Depends) rather than importing backends.
get_clock ¶
get_clock(request)
Return the clock provider.
Source code in jasil/deps.py
39 40 41 | |
get_events ¶
get_events(request)
Return the event-bus provider.
Source code in jasil/deps.py
29 30 31 | |
get_lock ¶
get_lock(request)
Return the coordination-lock provider.
Source code in jasil/deps.py
34 35 36 | |
get_platform ¶
get_platform(request)
Return the process-wide Platform from application state.
Source code in jasil/deps.py
14 15 16 | |
get_state ¶
get_state(request)
Return the ephemeral-state provider.
Source code in jasil/deps.py
19 20 21 | |
get_storage ¶
get_storage(request)
Return the blob-storage provider.
Source code in jasil/deps.py
24 25 26 | |
Durable jobs¶
jasil.jobs.registry ¶
Registry mapping a durable subscriber_id to its handler and event type.
A durable subscriber declares a stable id (independent of its Python module path) and the event type it reacts to, and registers a handler here. The relay uses the event-type mapping to fan an event out into one job per subscriber; the worker uses the id mapping to resolve a claimed job back to its handler. The same code therefore runs subscribers in-process or out-of-process in a worker.
JobHandlerRegistry ¶
A process-local registry of durable subscribers.
Source code in jasil/jobs/registry.py
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | |
clear ¶
clear()
Remove every registration (used to reset state between tests).
Source code in jasil/jobs/registry.py
87 88 89 90 | |
get ¶
get(subscriber_id)
Look up the handler for a subscriber id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscriber_id
|
str
|
The durable-subscriber identifier to resolve. |
required |
Returns:
| Type | Description |
|---|---|
JobHandler | None
|
The registered handler, or |
Source code in jasil/jobs/registry.py
54 55 56 57 58 59 60 61 62 63 64 | |
register ¶
register(event_type, subscriber_id, handler)
Register (or replace) a durable subscriber.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The domain-event channel the subscriber reacts to. |
required |
subscriber_id
|
str
|
The stable durable-subscriber identifier. |
required |
handler
|
JobHandler
|
The callable that processes an event for this subscriber. |
required |
Returns:
| Type | Description |
|---|---|
None
|
None. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/jobs/registry.py
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
subscriber_ids ¶
subscriber_ids()
Return every registered subscriber id, across all event types.
Returns:
| Type | Description |
|---|---|
frozenset[str]
|
The registered durable-subscriber ids. |
Source code in jasil/jobs/registry.py
78 79 80 81 82 83 84 85 | |
subscribers_for ¶
subscribers_for(event_type)
Return the durable subscriber ids registered for an event type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The domain-event channel to fan out. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, ...]
|
The subscriber ids, in registration order. |
Source code in jasil/jobs/registry.py
66 67 68 69 70 71 72 73 74 75 76 | |
jasil.jobs.reconciliation ¶
The reconciliation-net contract every durable subscriber is held to.
A durable subscriber reacts to an event to derive state. Delivery is at-least-once but never guaranteed: a Redis-Streams consumer can drop a message, a provider can be briefly down, and some write paths publish no event at all (a bulk import that persists rows directly). So a subscriber that writes durable derived state must ship a scheduled backfill that re-derives whatever the create path missed.
The vocabulary lives in the substrate rather than in whichever module happened to
need it first. A module owning the type would force every other module to import
it just to declare a net — a dependency between two bounded contexts for the sake
of a shared word. Owning it here is what lets every module declare its nets
without depending on any other, and what lets one conformance test
(:func:assert_nets_complete) hold them all to it.
DurableSubscriberNet
dataclass
¶
A durable subscriber's reconciliation net, or a documented exemption.
Exactly one of backfill / exempt_reason is set. Neither is not an
option: a subscriber with no net and no stated reason is one whose derived
state silently goes missing, which is the failure this declaration exists to
make impossible to introduce by omission.
Attributes:
| Name | Type | Description |
|---|---|---|
subscriber_id |
str
|
The stable durable-subscriber id (as registered on the
:class: |
backfill |
Callable[[], None] | None
|
The scheduled, argument-free backfill that re-derives anything
the create-path handler missed, or |
exempt_reason |
str | None
|
Why no backfill is required, when |
Source code in jasil/jobs/reconciliation.py
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | |
__post_init__ ¶
__post_init__()
Reject a declaration that sets both fields, or neither.
Returns:
| Type | Description |
|---|---|
None
|
None. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/jobs/reconciliation.py
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | |
assert_nets_complete ¶
assert_nets_complete(nets, *, registry)
Fail unless every registered durable subscriber declares a net.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nets
|
Iterable[DurableSubscriberNet]
|
The reconciliation nets declared across every module. |
required |
registry
|
JobHandlerRegistry
|
The registry holding the durable subscribers to check. |
required |
Returns:
| Type | Description |
|---|---|
None
|
None. |
Raises:
| Type | Description |
|---|---|
AssertionError
|
When a registered subscriber declares no net. This is a host's conformance test in one call, where an assertion is the idiom. |
Source code in jasil/jobs/reconciliation.py
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
undeclared_subscribers ¶
undeclared_subscribers(nets, *, registry)
Return the registered subscriber ids that no net accounts for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nets
|
Iterable[DurableSubscriberNet]
|
The reconciliation nets declared across every module. |
required |
registry
|
JobHandlerRegistry
|
The registry holding the durable subscribers to check — normally the process-wide one, with every subscriber module imported. |
required |
Returns:
| Type | Description |
|---|---|
frozenset[str]
|
The subscriber ids present in the registry but absent from |
frozenset[str]
|
Empty when every durable subscriber is accounted for. |
Source code in jasil/jobs/reconciliation.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | |
jasil.jobs.runner ¶
The job runner — claims durable jobs and executes their subscribers.
Records outcomes to processing_jobs only (the per-subscriber execution
state), never to event_log (which stays a one-row-per-event publication
log). A successful run marks the job completed; a failure reschedules it
with backoff, or dead-letters it once the attempt ceiling is reached.
ClaimedJob
dataclass
¶
A detached snapshot of a claimed job, safe to use after its session closes.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The job id. |
event_id |
str
|
The originating envelope event_id. |
event_type |
str
|
The domain-event channel. |
subscriber_id |
str
|
The durable subscriber to run. |
source |
str
|
Where the originating event came from. |
payload |
dict
|
The domain payload. |
metadata |
dict | None
|
Correlation context, if any. |
attempts |
int
|
The attempt number this run represents. |
timestamp |
str
|
ISO-8601 enqueue time, used to rebuild the event envelope. |
schema_version |
int
|
The payload-shape version carried from the envelope, so the handler can upgrade or refuse a payload written by another build. |
Source code in jasil/jobs/runner.py
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
JobRunner ¶
Claims a batch of due jobs and runs each one's registered subscriber.
Source code in jasil/jobs/runner.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | |
reap_once ¶
reap_once()
Requeue (or dead-letter) jobs whose lease expired.
Returns:
| Type | Description |
|---|---|
int
|
The number of jobs reclaimed. |
Source code in jasil/jobs/runner.py
109 110 111 112 113 114 115 116 117 118 | |
run_once ¶
run_once()
Claim and process one batch of due jobs.
Returns:
| Type | Description |
|---|---|
int
|
The number of jobs processed in this batch (0 when none were due). |
Source code in jasil/jobs/runner.py
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | |
jasil.jobs.service ¶
Wiring for durable job processing — worker lifecycle and scheduled maintenance.
Consumed by main (start/stop the in-process worker) and the scheduler (relay
the outbox into jobs; reap expired leases). The relay, reaper, and worker all run
on every process and coordinate through SELECT ... FOR UPDATE SKIP LOCKED plus
the idempotent job fan-out (dedup on event_id + subscriber_id), so replicas
scale horizontally without a single-runner lock — duplicate work is skipped or
deduplicated rather than serialized. All of this is inert unless JOBS_ENABLED
is set.
build_runner ¶
build_runner()
Build a :class:JobRunner from settings and the active platform.
Returns:
| Type | Description |
|---|---|
JobRunner
|
A runner wired to the durable-subscriber registry, the platform clock, |
JobRunner
|
and the main-database session factory. |
Source code in jasil/jobs/service.py
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | |
reap_expired_jobs_scheduled ¶
reap_expired_jobs_scheduled()
Requeue or dead-letter jobs with expired leases.
Runs on every replica; reclaim_expired_leases uses FOR UPDATE SKIP
LOCKED so concurrent reapers reclaim disjoint rows.
Source code in jasil/jobs/service.py
108 109 110 111 112 113 114 115 116 117 118 | |
relay_outbox_scheduled ¶
relay_outbox_scheduled()
Relay the outbox into per-subscriber jobs.
Runs on every replica; FOR UPDATE SKIP LOCKED gives each relayer a
disjoint batch and the idempotent fan-out dedups any overlap, so no
single-runner lock is needed.
Source code in jasil/jobs/service.py
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
schedule_job_maintenance ¶
schedule_job_maintenance(scheduler)
Register the recurring relay and reaper jobs on the scheduler.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scheduler
|
AsyncIOScheduler
|
The application scheduler to register the jobs on. |
required |
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/jobs/service.py
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | |
start_job_worker ¶
start_job_worker()
Start the in-process job worker (idempotent).
Source code in jasil/jobs/service.py
67 68 69 70 71 72 73 74 | |
stop_job_worker ¶
stop_job_worker()
Stop the in-process job worker if it is running.
Source code in jasil/jobs/service.py
77 78 79 80 81 82 83 84 | |
Retention¶
jasil.retention ¶
Scheduled retention pruning for the substrate's append-only bookkeeping tables.
The event-log and durable-job tables are append-only: every event writes an
event_log row, the relay stamps event_outbox rows, and each
(event, subscriber) pair produces a processing_jobs row. Left alone they
grow without bound, fastest in whichever subsystem publishes most. This module
prunes rows past their retention window on a schedule (one window for the
event_log trail, another for the durable-job tables — each configured and
disabled independently), deleting only what is safe to lose:
- every
event_logrow (best-effort, safe-to-lose observability trail), - relayed
event_outboxrows (already fanned out into jobs), and completedprocessing_jobsrows.
In-flight and human-actionable rows are never touched: unrelayed outbox rows
(pending relay), pending / claimed jobs (in-flight work), and
dead_letter jobs (kept for operator review). It runs single-runner across
replicas via the platform LockProvider and is inert when both windows are
<= 0 (retention disabled — keep every row forever).
prune_expired_records ¶
prune_expired_records()
Prune substrate bookkeeping rows older than their retention windows.
Scheduled daily (and once at startup). Each window is applied independently:
EVENT_LOG_RETENTION_DAYS gates the event_log trail and
JOBS_RETENTION_DAYS gates the durable-job tables. No-ops when both are
disabled (<= 0) or when another replica already holds the prune lock.
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/retention.py
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | |
jasil.pruning ¶
Bounded batch deletes shared by the substrate's prunable tables.
Every table :mod:jasil.retention prunes (event_log, relayed
event_outbox rows, completed processing_jobs) is deleted the
same way, and only the model and the filter differ. The batching is what
keeps each delete transaction short, so a prune pass never holds locks on
a hot table long enough to block the relay or the worker.
bounded_delete ¶
bounded_delete(
model, *conditions, db, batch_size=PRUNE_BATCH_SIZE
)
Delete the rows matching conditions in bounded, committed batches.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
type[Any]
|
Mapped class to delete from; must expose an |
required |
*conditions
|
ColumnExpressionArgument[bool]
|
Filters selecting the rows that are safe to prune. |
()
|
db
|
Session
|
Active database session. |
required |
batch_size
|
int
|
Maximum rows deleted per batch. |
PRUNE_BATCH_SIZE
|
Returns:
| Type | Description |
|---|---|
int
|
The total number of rows deleted. |
Source code in jasil/pruning.py
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | |
Migrations¶
jasil.migrations ¶
Alembic migrations for JASIL's companion tables.
JASIL maps its tables into the host's declarative registry (see
:mod:jasil.orm), so its tables and the host's live in one database. To let
JASIL evolve its schema without owning the host's Alembic history, these
migrations run on their own version table (jasil_alembic_version) and are
scoped to JASIL's own tables — the host's tables are never touched.
Requires the optional jasil[migrations] extra (Alembic). import jasil
never pulls this in; import it explicitly::
import jasil.orm as jasil_orm
from jasil import migrations
jasil_orm.map_models(Base) # the metadata must exist first
migrations.upgrade(engine) # create/upgrade JASIL's tables
# migrations.stamp(engine) # existing DB already at head
# migrations.verify_schema_current(engine) # fail fast if not migrated
Hosts that prefer a single, unified Alembic history can instead point their own
env.py at their Base.metadata (the base passed to
:func:jasil.orm.map_models) and add this package's versions directory to
their version_locations — but the self-contained runner here needs no host
wiring.
db_revision ¶
db_revision(engine)
Return the JASIL migration revision currently recorded in engine.
Source code in jasil/migrations/__init__.py
129 130 131 132 133 134 135 136 | |
downgrade ¶
downgrade(engine, revision)
Downgrade JASIL's tables to revision ("base" drops them all).
Source code in jasil/migrations/__init__.py
106 107 108 | |
head_revision ¶
head_revision()
Return the newest revision shipped in this package.
Source code in jasil/migrations/__init__.py
121 122 123 124 125 126 | |
jasil_include_object ¶
jasil_include_object(
obj, name, type_, reflected, compare_to
)
Alembic include_object hook scoping operations to JASIL's tables.
Excludes every host table sharing the registry, so JASIL's migrations and autogenerate never add, drop, or diff them.
Source code in jasil/migrations/__init__.py
58 59 60 61 62 63 64 65 66 67 68 | |
stamp ¶
stamp(engine, revision='head')
Record revision without running migrations.
Use on an existing deployment whose JASIL tables were created with
Base.metadata.create_all (or an older release): stamping marks it as
being at head so future :func:upgrade calls apply only new revisions.
Source code in jasil/migrations/__init__.py
111 112 113 114 115 116 117 118 | |
upgrade ¶
upgrade(engine, revision='head')
Create or upgrade JASIL's tables to revision (default head).
Source code in jasil/migrations/__init__.py
101 102 103 | |
verify_schema_current ¶
verify_schema_current(engine)
Raise if the database is not migrated to the packaged head revision.
A fail-fast startup check: call it after configuring the engine to catch a forgotten upgrade before the first query does.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the recorded revision differs from the packaged head. |
Source code in jasil/migrations/__init__.py
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | |
Async bridge¶
jasil.async_bridge ¶
Bridge for dispatching async work onto the main event loop from sync code.
Synchronous code cannot await. Two situations in this backend need to run
genuinely async I/O from a synchronous context:
- a synchronous FastAPI route, which Starlette runs on a threadpool worker thread (no running event loop of its own), and
- an in-process event-bus subscriber, which runs inline on whatever thread called
publish(a request thread, the scheduler, or a durable-job worker).
When such code must perform async I/O — pushing a message over a live connection
is the
motivating case — it hands the coroutine to :func:dispatch, which schedules it
on the main event loop captured at application startup via
:func:asyncio.run_coroutine_threadsafe. This keeps the async work on the one
loop that owns the resources (e.g. the websocket connections) while letting the
producing code stay fully synchronous.
The main loop is a process-wide handle (like the platform handle in
:mod:jasil.runtime), so it lives in a module-level slot set once from the
lifespan startup and cleared on shutdown. This module imports nothing from the
domain layer or any backend, so it is safe for any module to depend on.
capture_running_loop ¶
capture_running_loop()
Capture the currently running loop as the main loop.
Convenience for the lifespan startup: keeps the asyncio detail here rather
than in main. Must be called from within a running event loop.
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/async_bridge.py
54 55 56 57 58 59 60 61 62 63 | |
dispatch ¶
dispatch(coro)
Schedule coro on the main event loop from any thread; fire-and-forget.
Thread-safe. Intended for synchronous callers (sync routes, in-process subscribers) that need to run async I/O without awaiting it. Failures raised by the coroutine are logged (never surfaced to the caller), so a delivery failure — e.g. a dropped websocket — cannot break the synchronous work that triggered it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coro
|
Coroutine[Any, Any, Any]
|
The coroutine to run on the main loop. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
The |
Future[Any] | None
|
class: |
Future[Any] | None
|
or |
|
Future[Any] | None
|
the coroutine is closed so it does not emit a "coroutine was never |
|
Future[Any] | None
|
awaited" warning. |
Source code in jasil/async_bridge.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | |
get_main_loop ¶
get_main_loop()
Return the captured main event loop, or None if it is not set.
Returns:
| Type | Description |
|---|---|
AbstractEventLoop | None
|
The main event loop when the application is running, else |
AbstractEventLoop | None
|
(e.g. in unit tests, or before startup / after shutdown). |
Source code in jasil/async_bridge.py
66 67 68 69 70 71 72 73 | |
set_main_loop ¶
set_main_loop(loop)
Record (or clear) the main event loop.
Called once from the lifespan startup with the running loop, and again on
shutdown with None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
loop
|
AbstractEventLoop | None
|
The running main event loop, or |
required |
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/async_bridge.py
38 39 40 41 42 43 44 45 46 47 48 49 50 51 | |