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 ¶
Composable provider interfaces and complete platform capabilities.
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
363 364 365 366 367 368 | |
EventBusProvider ¶
Bases: Protocol
Publish/subscribe — synchronous in-process, or durable via Redis Streams.
Source code in jasil/providers.py
312 313 314 315 316 317 318 319 | |
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).
Implementations may additionally offer record_queued_in(event, db), which
stages the queued row on the caller's session so it commits or rolls back
with the outbox row. It is optional and deliberately not part of this
protocol (SemVer); the publisher uses it when present and otherwise falls
back to :meth:record_queued.
Source code in jasil/providers.py
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | |
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
371 372 373 374 375 376 377 378 379 380 381 382 383 | |
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
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 | |
LockProvider ¶
Bases: Protocol
Best-effort mutual exclusion for scheduled/backfill work.
Source code in jasil/providers.py
356 357 358 359 360 | |
MailError ¶
Bases: RuntimeError
A message could not be handed to the mail server.
The message carries a failure category and, when there is one, the SMTP
status code — never an address, a subject, a body or server-supplied text,
because a job runner persists str(error) and logs it.
Attributes:
| Name | Type | Description |
|---|---|---|
status |
The SMTP status code, or |
Source code in jasil/providers.py
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | |
MailMessage
dataclass
¶
One outbound email, already built by the host.
Attributes:
| Name | Type | Description |
|---|---|---|
to |
Sequence[str]
|
Recipient addresses, each a bare |
subject |
str
|
Subject line. Must not contain CR or LF. |
text |
str | None
|
Plain-text body. At least one of |
html |
str | None
|
HTML body. With |
reply_to |
str | None
|
Optional single |
Source code in jasil/providers.py
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 | |
MailNotConfiguredError ¶
Bases: MailError
Mail was sent while no backend is configured.
Source code in jasil/providers.py
452 453 | |
MailProvider ¶
Bases: Protocol
Outbound email — hand a built message to a mail server.
Unlike geocoding, mail is not best-effort enrichment: a silently dropped message is a bug, and a raised exception is what makes a durable job retry.
send returns the Message-ID the backend assigned, angle brackets
included, exactly as it appears in the header.
Raises:
| Type | Description |
|---|---|
MailError
|
On any delivery failure (:class: |
ValueError
|
On an invalid message — no recipient, no body, a malformed address, or CR/LF in a header value. A programming error, not a delivery error. |
Source code in jasil/providers.py
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 | |
ObjectStat
dataclass
¶
Metadata available without reading an object's content.
content_type and etag are optional because portable local
filesystems expose neither. An ETag is an opaque backend validator, not
necessarily a digest of the object bytes.
Source code in jasil/providers.py
67 68 69 70 71 72 73 74 75 76 77 78 79 | |
PartRef
dataclass
¶
Backend-validated reference to one uploaded part.
validator is an opaque equality token. It may be a digest, an object
storage ETag, or another backend value; callers must not interpret it.
Source code in jasil/providers.py
54 55 56 57 58 59 60 61 62 63 64 | |
ResumableUploads ¶
Bases: Protocol
Durable multipart upload lifecycle and abandoned-session cleanup.
upload_part reads source once without seeking or closing it. The
required size must exactly match the source; short and long sources raise
:class:ValueError without committing that part. A failed attempt may have
consumed the source, so retry with a newly opened source and the same session
and part number. Limits reported by :class:UploadSession are JASIL's
built-in portability limits, shared by every built-in backend so callers
never branch on the selected backend.
Source code in jasil/providers.py
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 251 252 | |
ServeFile
dataclass
¶
Serve a local file directly, using sendfile or a reverse-proxy mapping.
Source code in jasil/providers.py
82 83 84 85 86 | |
ServeRedirect
dataclass
¶
Redirect the client to a backend-generated object URL.
Source code in jasil/providers.py
89 90 91 92 93 | |
ServeStream
dataclass
¶
Proxy an object through the host using a read-once binary stream.
Source code in jasil/providers.py
96 97 98 99 100 | |
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
20 21 22 23 24 25 26 | |
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
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 | |
StorageBackendUnavailableError ¶
Bases: RuntimeError
Raised when a storage backend cannot complete an operation.
Source code in jasil/providers.py
29 30 | |
StorageDelivery ¶
Bases: Protocol
Backend-efficient delivery without coupling to a web framework.
Source code in jasil/providers.py
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | |
StorageManagement ¶
Bases: Protocol
Object-set mutation and backend readiness operations.
Source code in jasil/providers.py
212 213 214 215 216 217 218 | |
StorageObjects ¶
Bases: Protocol
Whole-object persistence, metadata, deletion, and enumeration.
Source code in jasil/providers.py
152 153 154 155 156 157 158 159 160 161 162 | |
StorageProvider ¶
Bases: StorageObjects, StorageStreams, StorageDelivery, StorageManagement, ResumableUploads, Protocol
Complete object-storage capability assembled by :class:Platform.
An area is a domain-owned namespace (e.g. "avatars", "exports") so
one backend serves every subsystem. 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.
save and get are the simple whole-object path for small blobs.
save_stream and open_stream are the bounded-memory path for large
objects. Streaming writes are atomic: exceeding max_bytes raises
:class:StorageSizeLimitError and never exposes a partial new object.
Streaming reads are read-once and non-seekable on every backend. offset
and length select a byte range without requiring the returned stream to
seek; a missing object raises :class:FileNotFoundError.
Resumable writes use begin_upload, one or more upload_part calls,
then complete_upload or abort_upload. Sessions are portable across
processes sharing the configured backend and report their part constraints.
cleanup_uploads aborts sessions initiated before a caller-owned cutoff.
list_keys is the convenient materialized listing for small namespaces.
iter_objects lazily yields (key, modified_epoch) for reconciliation
jobs that may scan millions of objects and need an age guard.
serve selects the backend's efficient delivery primitive without
importing a web framework: local storage returns :class:ServeFile, S3
returns :class:ServeRedirect, and a backend with neither capability may
return :class:ServeStream. It verifies that the object exists first.
stat returns size and modification time plus backend metadata when
available. copy keeps object bytes inside the backend. delete_prefix
deletes one non-empty key root and its slash-delimited descendants, not
adjacent lexical prefixes ("pkg/1" never selects "pkg/10").
check_writable performs a write-and-delete readiness probe and raises
:class:StorageBackendUnavailableError when the configured store cannot
persist objects.
expires_in is best-effort, and the one place the backends genuinely
differ: s3:// returns a presigned URL that honours expiry and response
header overrides, while local:// returns a plain path for the host's own
web server and cannot enforce any of them. Do not rely on these controls
unless you know the deployment uses object storage; on local disk they are
host policy. The local backend logs a warning the first time it is handed a
control it cannot honour.
Source code in jasil/providers.py
255 256 257 258 259 260 261 262 263 264 265 266 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 | |
StorageSizeLimitError ¶
Bases: ValueError
Raised when a streaming write exceeds its configured byte limit.
Source code in jasil/providers.py
33 34 | |
StorageStreams ¶
Bases: Protocol
Bounded-memory object reads and writes.
Source code in jasil/providers.py
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | |
StorageUploadSessionError ¶
Bases: RuntimeError
Raised when a resumable upload session or part reference is not active.
Source code in jasil/providers.py
37 38 | |
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
106 107 108 109 110 111 112 113 114 115 116 117 118 119 | |
UploadSession
dataclass
¶
Durable handle and portable limits for one resumable upload.
Source code in jasil/providers.py
41 42 43 44 45 46 47 48 49 50 51 | |
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.
payload and metadata are stored and transmitted verbatim: both are
written to event_outbox and processing_jobs, to event_log when the
trail is enabled, and serialized onto the Redis stream on the redis://
bus — and metadata is additionally logged in full when a best-effort
subscriber fails. Neither is size-capped. So carry identifiers, not secrets
and not blobs; see the events documentation for the full contract.
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 97 98 99 100 101 102 103 | |
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
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 | |
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 119 120 121 | |
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. Plain :func:publish
commits the outbox row on its own, so a crash between the domain commit and the
outbox write can drop an event; :func:publish_committing and
:func:publish_on_commit stage it in the caller's transaction instead. Every
subscriber should still have a reconciliation net — a backfill or sweeper that
re-derives missed work. "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
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 | |
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
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 | |
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
260 261 262 263 264 265 266 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 | |
publish_many_on_commit ¶
publish_many_on_commit(
event_type,
payloads,
*,
source,
metadata_for=None,
db,
schema_version=INITIAL_SCHEMA_VERSION,
)
Publish many same-type events tied to db's commit.
The batch counterpart of :func:publish_on_commit, with the same delivery
routing and the same guarantee: best-effort events dispatch, in order, only
after the session's outermost transaction commits. An empty sequence is a
no-op; it neither stages anything nor starts a transaction.
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. |
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 |
schema_version
|
int
|
Payload schema version. |
INITIAL_SCHEMA_VERSION
|
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/publisher.py
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 | |
publish_on_commit ¶
publish_on_commit(
event_type,
payload,
*,
source,
metadata=None,
db,
schema_version=INITIAL_SCHEMA_VERSION,
)
Publish a domain event tied to db's commit, without owning the commit.
The caller stages the event and whoever owns the transaction commits whenever it likes, so the publishing function composes with other writes in one atomic unit of work.
- Durable delivery enabled (durable jobs on + a durable subscriber for
event_type): the outbox row is staged ondbimmediately, uncommitted, and commits (or rolls back) atomically with the caller's domain rows. A staging failure propagates to the caller. - Otherwise (best-effort bus path): the event is minted now (so the ambient
request id is captured) and held on the session. It is dispatched on the bus
only after
db's outermost transaction commits, in publish order. An event published inside a SAVEPOINT that is rolled back, or in a transaction that is rolled back or closed, is discarded and never dispatched. Dispatch failures are logged and swallowed.
"Outermost" is the session's outermost transaction. If the session joined an
external transaction (join_transaction_mode="create_savepoint"), its
commit() is that session's outermost commit even though the enclosing
transaction may still roll back.
Publishing starts the session's transaction if none is active. Sessions are
synchronous sqlalchemy.orm.Session instances; each carries its own pending
events, so concurrent sessions never see each other's.
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 change). |
required |
schema_version
|
int
|
Payload schema version. |
INITIAL_SCHEMA_VERSION
|
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/publisher.py
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 | |
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 60 | |
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), the configured number of web workers, and whether durable jobs run inside the API processes or in standalone worker processes (JobWorkerMode). - What does that shape imply?
DeploymentTopology.multi_process(two processes can reach the same capability) andDeploymentTopology.multi_node(they may be on different machines).resolve_topologyis the one place that knows which inputs imply which fact, so a new kind of process is a single line there and every consistency rule picks it up.
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
26 27 28 29 30 31 32 33 34 35 36 37 38 39 | |
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). |
job_worker_mode |
JobWorkerMode
|
Where durable-job consumers run. |
Source code in jasil/profile.py
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 | |
multi_node
property
¶
multi_node
Whether those processes may run on different machines.
Only the distributed profile promises that. Several processes of a
local deployment share one host, and with it one disk.
multi_process
property
¶
multi_process
Whether two or more processes can reach the same capability.
True for the distributed profile, for more than one web worker, and
whenever durable-job consumers run as standalone processes. Process-local
memory, an in-process lock or a recording mail backend cannot be shared
between them.
multi_process_causes
property
¶
multi_process_causes
What makes this topology multi-process; empty for a single process.
Each entry is a short phrase naming the setting behind it, so a refusal can tell an operator why their deployment counts as multi-process.
multi_process_reason
property
¶
multi_process_reason
:attr:multi_process_causes joined into one phrase for a refusal message.
JobWorkerMode ¶
Bases: StrEnum
Where durable-job consumers run.
A fact about the deployment's shape, not a count: it does not change when workers are scaled, and every process of a deployment loads the same value, so the API replicas and the workers agree on the shape without coordinating.
Attributes:
| Name | Type | Description |
|---|---|---|
IN_PROCESS |
Each API process starts its own consumer with
|
|
STANDALONE |
Consumers are dedicated processes started with
|
Source code in jasil/profile.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | |
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
62 63 64 65 66 67 68 69 70 71 72 73 | |
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
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | |
parse_job_worker_mode ¶
parse_job_worker_mode(value)
Parse a raw job-worker-mode value into a JobWorkerMode.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str | JobWorkerMode | None
|
Raw environment value, an existing mode, or |
required |
Returns:
| Type | Description |
|---|---|
JobWorkerMode
|
The parsed mode; |
Raises:
| Type | Description |
|---|---|
ValueError
|
When the value is a non-empty, unrecognized mode name.
Raising (rather than defaulting) prevents a typo like |
Source code in jasil/profile.py
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 | |
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
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 | |
resolve_topology ¶
resolve_topology(*, profile, web_workers, job_worker_mode)
Resolve the deployment topology, clamping web_workers to >= 1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
profile
|
DeploymentProfile
|
The deployment profile. |
required |
web_workers
|
int
|
Configured web worker count. |
required |
job_worker_mode
|
JobWorkerMode
|
Where durable-job consumers run. |
required |
Returns:
| Type | Description |
|---|---|
DeploymentTopology
|
The resolved |
Source code in jasil/profile.py
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | |
Capability report¶
jasil.capabilities ¶
Startup capability report and deployment-consistency checks.
Renders how each infrastructure capability (state, storage, events, lock, mail, 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.
Every rule asks one question of a :class:~jasil.profile.DeploymentTopology
rather than re-deriving the deployment's shape from raw settings: can two
processes reach this capability (multi_process), or two machines
(multi_node)? What makes a topology multi-process — the distributed
profile, more than one web worker, or standalone job workers — is decided in one
place, :func:~jasil.profile.resolve_topology, so a new kind of process needs no
change here.
Five 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 is multi-process; the stores and the bus would
diverge silently across processes. Both share the
memory:///redis://vocabulary and are validated by :func:check_state_consistency. - Cross-node storage must not resolve to the local filesystem when the
topology is multi-node (the
distributedprofile), where replicas run on separate nodes with no shared disk. Several processes of a local deployment share 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 when the topology is multi-process, or every process would run every scheduled job. Validated by :func:check_lock_consistency. - Mail must not resolve to the recording
memory://backend when the topology is multi-process: every process would keep its own outbox and no message would ever be delivered. Validated by :func:check_mail_consistency. - Job workers may not be declared standalone while durable jobs are
disabled: the declaration would make every process stricter for consumers that
cannot exist. Validated by :func:
check_job_worker_consistency.
The clock is always the system clock, so its default is never a fatal choice here.
:func:check_deployment_consistency applies all five 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
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | |
render ¶
render()
Render the report as a multi-line, aligned string.
Source code in jasil/capabilities.py
110 111 112 113 114 115 116 117 118 119 120 121 | |
CapabilityRow
dataclass
¶
One line of the capability report.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Capability name (state / storage / events / lock / mail / clock). |
backend |
str
|
The resolved backend (e.g. |
source |
str
|
Where the value came from (setting name or |
Source code in jasil/capabilities.py
88 89 90 91 92 93 94 95 96 97 98 99 100 | |
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
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | |
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
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 | |
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 five 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
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 | |
check_job_worker_consistency ¶
check_job_worker_consistency(*, topology, jobs_enabled)
Return a fatal issue when standalone job workers are declared with jobs off.
job_worker_mode=standalone tells every process that other processes
consume durable jobs, which makes the other rules stricter. With
jobs.enabled false the jobs layer is dormant, so no such consumer can
exist and the declaration would only impose constraints nothing justifies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
topology
|
DeploymentTopology
|
The deployment topology. |
required |
jobs_enabled
|
bool
|
Whether the durable-job pipeline is enabled. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
A single-item issue list when contradictory; empty otherwise. |
Source code in jasil/capabilities.py
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 | |
check_lock_consistency ¶
check_lock_consistency(*, topology, 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 the topology is multi-process, 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:// under distributed, so this
trips on an explicit lock_uri="noop://" override, or on a local
default paired with several processes. A single-process deployment has
nothing to coordinate, so noop stays valid there.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
topology
|
DeploymentTopology
|
The deployment topology. |
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
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | |
check_mail_consistency ¶
check_mail_consistency(*, topology, mail_uri, mail_label)
Return a fatal issue when a multi-process deployment records mail in memory.
memory:// keeps each message in the sending process's outbox and never
delivers it. In a single process that is a test fixture; in a multi-process
deployment — including a standalone job worker, whose handlers are where mail
is sent — it is production mail silently going nowhere. Unset mail is not
flagged: it raises on every send, so it cannot drop a message unnoticed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
topology
|
DeploymentTopology
|
The deployment topology. |
required |
mail_uri
|
str | None
|
The configured mail URI, or |
required |
mail_label
|
str
|
The setting backing mail (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
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 | |
check_state_consistency ¶
check_state_consistency(*, topology, state_sources)
Return fatal issues for cross-process backends (empty when consistent).
A multi-process deployment that 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 |
|---|---|---|---|
topology
|
DeploymentTopology
|
The deployment topology. |
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
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | |
check_storage_consistency ¶
check_storage_consistency(
*, topology, storage_uri, storage_label
)
Return a fatal issue when multi-node 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. Several processes
of a local deployment share one host disk, so local storage stays valid
there and is not flagged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
topology
|
DeploymentTopology
|
The deployment topology. |
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
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
topology_of ¶
topology_of(settings)
Return the deployment topology settings describe.
The one place that turns settings into a topology, so the report, every rule and the job-worker entry points agree on what shape a deployment has.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
JasilSettings
|
The configuration the platform is being built from. |
required |
Returns:
| Type | Description |
|---|---|
DeploymentTopology
|
The resolved topology. |
Source code in jasil/capabilities.py
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | |
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:MailSettings,
: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
97 98 99 100 101 102 103 104 105 106 107 108 | |
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
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 | |
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 |
job_worker_mode |
JobWorkerMode
|
Where durable-job consumers run: |
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. |
mail |
MailSettings
|
Outbound-mail backend configuration. |
network |
NetworkSettings
|
Outbound-egress configuration. |
Source code in jasil/settings.py
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 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 | |
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 worker may go silent before the reaper may
reclaim the jobs it holds. A running worker renews its leases on every
heartbeat, so this is not a cap on handler runtime (see
|
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_runtime_seconds |
int
|
The longest a single handler is kept leased. Past it
the job is abandoned to the reaper, the handler is told to stop
( |
record_attempts |
bool
|
Write one |
heartbeat_interval_seconds |
float
|
Interval between worker heartbeats, each of which renews held leases and refreshes the worker-registry row. |
max_attempts |
int
|
Attempts before a job is dead-lettered. |
retention_days |
int
|
Age at which relayed outbox rows, completed jobs, their
attempt history, and stopped and stale worker rows are pruned. A job
that is pending, claimed or dead-lettered keeps its whole history.
|
Source code in jasil/settings.py
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 91 92 93 94 | |
MailSettings
dataclass
¶
Outbound-mail backend configuration.
Attributes:
| Name | Type | Description |
|---|---|---|
uri |
str | None
|
|
from_address |
str
|
Sender identity, |
secure |
bool
|
|
username |
str | None
|
SMTP login, when the server requires authentication. |
password |
str | None
|
SMTP password. Required with |
ehlo_hostname |
str | None
|
Name announced in |
Source code in jasil/settings.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 | |
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. Prefer a CIDR: a hostname entry exempts every address that name resolves to, including a cloud metadata endpoint. |
Source code in jasil/settings.py
176 177 178 179 180 181 182 183 184 185 186 187 188 | |
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
291 292 293 294 295 296 297 | |
get_settings ¶
get_settings()
Return the installed settings, or the all-defaults instance.
Source code in jasil/settings.py
300 301 302 | |
is_configured ¶
is_configured()
Return whether :func:configure has been called.
Source code in jasil/settings.py
305 306 307 | |
reset ¶
reset()
Restore the all-defaults settings.
For tests; production code configures once at startup and never resets.
Source code in jasil/settings.py
310 311 312 313 314 315 | |
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, job_workers, and
job_attempts — 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 model module is imported. The model
modules bind their classes to the active base at import time, so importing one
first is a configuration error and raises. A host that would rather not own a
base may call map_models() with no argument and use JASIL's convenience
:data:Base.
Two modules reach a model directly and therefore inherit that ordering
constraint: :mod:jasil.jobs.crud and :mod:jasil.event_log.crud. Import
them inside a function, or only after map_models has run. Every other
public entry point — :mod:jasil.publisher, :mod:jasil.retention,
:mod:jasil.jobs.service, :mod:jasil.container, :mod:jasil.deps,
:mod:jasil.testing — defers its model imports and is safe to import from
anywhere, at any point in the host's import graph.
-
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
71 72 73 74 75 76 77 | |
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
172 173 174 175 176 177 178 179 180 181 182 | |
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
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | |
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
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | |
get_sessionmaker ¶
get_sessionmaker()
Return the installed session factory.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :func: |
Source code in jasil/orm.py
185 186 187 188 189 190 191 | |
is_models_mapped ¶
is_models_mapped()
Return whether :func:map_models has been called.
Source code in jasil/orm.py
129 130 131 | |
is_sessionmaker_configured ¶
is_sessionmaker_configured()
Return whether :func:configure_sessionmaker has been called.
Source code in jasil/orm.py
194 195 196 | |
jasil_table_names ¶
jasil_table_names()
Return the names of the tables JASIL owns.
Source code in jasil/orm.py
103 104 105 | |
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
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 | |
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
219 220 221 222 223 224 225 226 227 | |
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 |
mail |
MailProvider
|
Outbound-mail provider. Always present — when mail is unconfigured
this is a backend whose |
Source code in jasil/container.py
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 | |
close ¶
close()
Release what the platform owns: the bus consumer and the Redis clients.
Call once on shutdown, after the host has stopped producing events. Only the two capabilities that hold something beyond the process are affected — the rest are pure or borrow the host's engine, which the host closes.
Two things it deliberately does not stop, because the platform does not
own them: the durable-job worker and its scheduled maintenance (see
jasil.jobs.service.stop_job_worker), and the database engine.
Safe to call more than once, and never raises: a failure to shut a connection down must not mask whatever prompted the shutdown.
Source code in jasil/container.py
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
build_platform ¶
build_platform(settings=None, *, clock=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
|
clock
|
ClockProvider | None
|
Time source to wire into the platform and the backends that stamp
time (mail |
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, when the resolved
wiring contradicts the deployment topology, or when |
RuntimeError
|
When a selected Redis backend cannot be reached. |
Source code in jasil/container.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 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | |
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
54 55 56 57 58 59 60 61 62 63 | |
is_platform_active ¶
is_platform_active()
Return whether a platform has been published.
Returns:
| Type | Description |
|---|---|
bool
|
True once :func: |
Source code in jasil/runtime.py
45 46 47 48 49 50 51 | |
reset ¶
reset()
Unpublish the active platform.
For tests that build a platform per case; production publishes once at
startup and never resets. This does not close the platform — call
:meth:jasil.container.Platform.close first if it owns live connections.
Source code in jasil/runtime.py
66 67 68 69 70 71 72 73 74 | |
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 | |
Shutdown¶
jasil.lifecycle ¶
Ordered shutdown of everything JASIL owns in a process.
Two things outlive a request and have to be wound down deliberately: the durable-job worker thread, and the platform's own resources — the event-bus consumer thread and the shared Redis clients. The order matters. The worker runs subscribers, and a subscriber that publishes needs the bus still up, so the worker stops first.
They are two calls underneath because :mod:jasil.container must not import
:mod:jasil.jobs: the jobs layer imports APScheduler at module scope, and a
deployment that never enables durable jobs has to be able to build a platform
without the jobs extra installed at all. This module is the seam that
composes them, so the ordering lives in one place instead of in every host::
import jasil.lifecycle as jasil_lifecycle
@asynccontextmanager
async def lifespan(app: FastAPI):
...
yield
jasil_lifecycle.shutdown()
What it deliberately leaves alone is what the host owns: the APScheduler
instance passed to schedule_job_maintenance, and the database engine behind
the session factory. JASIL never created either, so it does not close them.
shutdown ¶
shutdown()
Stop the durable-job worker, release the platform, and unpublish it.
Idempotent, and safe to call when nothing was ever started — a process that only ever published events has no worker and may have no platform.
Never raises. Shutdown runs while something else is often already going wrong, and a failure to release a connection must not mask it; each step logs and continues instead.
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/lifecycle.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
FastAPI dependencies¶
jasil.deps ¶
FastAPI dependencies exposing the platform providers to routes and handlers.
These thin dependencies resolve the assembled Platform so routes depend on
providers (via Depends) rather than importing backends.
Two places are consulted, in order:
request.app.state.platform, when the host attached one. Use this to give a single process more than one platform — two apps mounted together, or a test client — since it is scoped to the app rather than to the process.- The process-wide platform published by
jasil.runtime.set_active_platform, which is what the quick start does and what every non-request caller (the scheduler, the durable-job worker, a background thread) already resolves.
So a host that followed the quick start needs no extra wiring here, and one that
wants per-app isolation gets it by setting app.state.platform at startup.
get_clock ¶
get_clock(request)
Return the clock provider.
Source code in jasil/deps.py
72 73 74 | |
get_events ¶
get_events(request)
Return the event-bus provider.
Source code in jasil/deps.py
62 63 64 | |
get_lock ¶
get_lock(request)
Return the coordination-lock provider.
Source code in jasil/deps.py
67 68 69 | |
get_mail ¶
get_mail(request)
Return the outbound-mail provider.
Source code in jasil/deps.py
77 78 79 | |
get_platform ¶
get_platform(request)
Return the Platform for this request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Request
|
The incoming request, used to reach |
required |
Returns:
| Type | Description |
|---|---|
Platform
|
The app-scoped platform when the host attached one, else the |
Platform
|
process-wide platform. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When neither has been published, i.e. startup never ran. |
Source code in jasil/deps.py
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | |
get_state ¶
get_state(request)
Return the ephemeral-state provider.
Source code in jasil/deps.py
52 53 54 | |
get_storage ¶
get_storage(request)
Return the blob-storage provider.
Source code in jasil/deps.py
57 58 59 | |
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
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 | |
clear ¶
clear()
Remove every registration (used to reset state between tests).
Source code in jasil/jobs/registry.py
170 171 172 173 174 | |
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
111 112 113 114 115 116 117 118 119 120 121 | |
queue_assignments ¶
queue_assignments()
Return a copy of the {subscriber_id: queue} assignments.
Source code in jasil/jobs/registry.py
166 167 168 | |
queue_for ¶
queue_for(subscriber_id)
Return the queue assigned to a registered subscriber.
Raises:
| Type | Description |
|---|---|
LookupError
|
When registry state has no queue for |
Source code in jasil/jobs/registry.py
123 124 125 126 127 128 129 130 131 132 133 134 135 | |
register ¶
register(
event_type,
subscriber_id,
handler,
*,
queue=DEFAULT_QUEUE,
)
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 |
queue
|
str
|
Durable-job queue assigned to this subscriber's fan-out. |
DEFAULT_QUEUE
|
Returns:
| Type | Description |
|---|---|
None
|
None. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When an identifier is invalid for the column it is written to. Checked at registration — startup — rather than when the relay first tries to enqueue a job, where the failure would be far from its cause. |
Source code in jasil/jobs/registry.py
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 | |
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
149 150 151 152 153 154 155 156 | |
subscriber_ids_for_queues ¶
subscriber_ids_for_queues(queues)
Return registered subscriber ids assigned to the selected queues.
Source code in jasil/jobs/registry.py
158 159 160 161 162 163 164 | |
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
137 138 139 140 141 142 143 144 145 146 147 | |
normalize_queue_selector ¶
normalize_queue_selector(queues)
Validate an optional non-empty queue allowlist and remove duplicates.
Source code in jasil/jobs/registry.py
59 60 61 62 63 64 65 66 | |
validate_queue_name ¶
validate_queue_name(queue)
Validate and return one portable durable-job queue identifier.
Queue names are deliberately lowercase ASCII so equality and ordering have the same meaning under SQLite, PostgreSQL, and MySQL collations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
queue
|
str
|
Queue identifier to validate. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The unchanged queue name. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When the queue is empty, overlong, or contains unsupported characters. |
Source code in jasil/jobs/registry.py
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
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. |
queue |
str
|
Named queue from which the job was claimed. |
Source code in jasil/jobs/runner.py
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 | |
JobRunner ¶
Claims a batch of due jobs and runs each one's registered subscriber.
Source code in jasil/jobs/runner.py
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 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 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 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 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 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | |
heartbeat_interval_seconds
property
¶
heartbeat_interval_seconds
How often the worker heartbeat renews leases and refreshes telemetry.
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
259 260 261 262 263 264 265 266 267 268 | |
run_once ¶
run_once(stop=None)
Claim and process one batch of due jobs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stop
|
Event | None
|
When set between jobs, the jobs not yet started are released back to the queue instead of being run, so a graceful shutdown does not hold them for the rest of the batch. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The number of jobs processed in this batch (0 when none were due). |
Source code in jasil/jobs/runner.py
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 184 185 186 187 | |
jasil.jobs.service ¶
Wiring for durable job processing — worker lifecycle and scheduled maintenance.
Consumed by application startup (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 compare-and-set claims and 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 durable jobs are enabled.
build_runner ¶
build_runner(*, queues=None)
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. |
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
queues
|
Iterable[str] | None
|
Optional non-empty queue allowlist. Omit it to consume all queues. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/jobs/service.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 | |
reap_expired_jobs_scheduled ¶
reap_expired_jobs_scheduled()
Requeue or dead-letter jobs with expired leases.
Runs on every replica; reclaim_expired_leases combines FOR UPDATE SKIP
LOCKED with a compare-and-set on status, so concurrent reapers reclaim
disjoint rows and a loser never overwrites the winner's requeue.
Source code in jasil/jobs/service.py
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
relay_outbox_scheduled ¶
relay_outbox_scheduled()
Relay the outbox into per-subscriber jobs.
Runs on every replica; each pass claims its batch with FOR UPDATE SKIP
LOCKED held across the fan-out, and the idempotent fan-out dedups any
overlap where the dialect cannot lock, so no single-runner lock is needed.
Source code in jasil/jobs/service.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 | |
run_job_worker ¶
run_job_worker(
*,
queues=None,
role=None,
label=None,
metadata=None,
stop=None,
)
Run a blocking standalone durable-job worker.
This is the supported process entrypoint for distributed deployments. The
host configures JASIL, registers its durable subscribers, and calls this
function without importing runner or CRUD internals. Register every durable
subscriber first, including those on queues this worker does not select: the
worker re-reads the registry on every claim, but retags legacy default rows
from it once, on its first batch. Signal handling remains
host-owned: pass an event that the host sets during graceful shutdown. This
function blocks until that event is set; run it only as a dedicated worker
process entrypoint, never on an API request or event-loop thread.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
queues
|
Iterable[str] | None
|
Optional non-empty queue allowlist. Omit it to consume all queues. |
None
|
role
|
str | None
|
Optional host-supplied worker role. |
None
|
label
|
str | None
|
Optional host-supplied operator label. |
None
|
metadata
|
dict[str, Any] | None
|
Optional host-supplied neutral metadata. |
None
|
stop
|
Event | None
|
Event ending the loop; a new unset event is used when omitted. |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When |
ValueError
|
When an explicit queue allowlist is empty or invalid. |
Source code in jasil/jobs/service.py
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 | |
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
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 | |
start_job_worker ¶
start_job_worker(*, role=None, label=None, metadata=None)
Start the in-process job worker (idempotent).
Belongs to job_worker_mode=in_process, the default: each API process runs
its own consumer. A deployment with dedicated worker processes declares
standalone and uses :func:run_job_worker instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
role
|
str | None
|
Optional host-supplied worker role. |
None
|
label
|
str | None
|
Optional host-supplied operator label. |
None
|
metadata
|
dict[str, Any] | None
|
Optional host-supplied neutral metadata. |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When |
Source code in jasil/jobs/service.py
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 | |
stop_job_worker ¶
stop_job_worker()
Stop the in-process worker from synchronous shutdown code.
Source code in jasil/jobs/service.py
226 227 228 229 230 231 232 233 | |
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, plus stopped/stale worker telemetry.
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.
Call it yourself, or register it with
:func:schedule_retention_maintenance. Each window is applied
independently: JasilSettings.event_log.retention_days gates the event_log
trail and JasilSettings.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
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 | |
schedule_retention_maintenance ¶
schedule_retention_maintenance(
scheduler, *, run_at_startup=True
)
Register the recurring retention prune on the host's scheduler.
The counterpart of :func:jasil.jobs.service.schedule_job_maintenance, kept
separate because retention also prunes the event_log — so it applies to a
deployment that never enabled durable jobs.
Register it on every replica. The prune takes the platform's coordination lock, so only one of them does the work per pass.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scheduler
|
AsyncIOScheduler
|
The application scheduler to register the job on. |
required |
run_at_startup
|
bool
|
Run one pass as soon as the scheduler starts. On by default because a daily interval otherwise means a process that is redeployed daily never prunes at all. |
True
|
Returns:
| Type | Description |
|---|---|
None
|
None. |
Source code in jasil/retention.py
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 | |
Admin and dashboards¶
jasil.admin ¶
The operator-facing surface: dashboard aggregates and dead-letter replay.
The event-log and durable-job tables are what an operator needs to see, and the
queries that aggregate them live in :mod:jasil.event_log.crud and
:mod:jasil.jobs.crud. Neither is a good thing for a host to wire a route to:
both reach a model at import time, so importing either before
:func:jasil.orm.map_models has run raises, and both take a session they then
commit — which, handed a session that already carries an admin request's own
uncommitted work, commits that too.
This module is the stable seam in front of them. It is safe to import from anywhere in the host's import graph (the model imports are deferred into the functions), and nothing here accepts a session: each call opens a short-lived one of its own, so a host cannot hand JASIL the transaction its request is in the middle of::
import jasil.admin as jasil_admin
@router.get("/admin/jobs")
def jobs_dashboard() -> jasil_admin.JobsSummary:
return jasil_admin.get_jobs_summary()
@router.post("/admin/jobs/{job_id}/replay")
def replay(job_id: str) -> jasil_admin.JobReplayResult:
return jasil_admin.replay_dead_letter_job(job_id)
The response schemas are re-exported here so a host can type its routes without importing from an internal module. Authentication and authorization are the host's: these functions expose operational data and one state-changing action, and JASIL has no notion of who is calling.
DeadLetterJob ¶
Bases: BaseModel
A dead-lettered job, shown for inspection and replay.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The job id (used to replay it). |
event_id |
str
|
The originating envelope event_id. |
event_type |
str
|
The event channel. |
subscriber_id |
str
|
The subscriber that failed. |
source |
str
|
Where the originating event came from. |
attempts |
int
|
Attempts made before dead-lettering. |
max_attempts |
int
|
The attempt ceiling that was reached. |
last_error |
str | None
|
The final failure reason. |
created_at |
datetime
|
When the job was enqueued. |
updated_at |
datetime
|
When the job was dead-lettered. |
completed_at |
datetime | None
|
When the job reached its terminal state. |
last_worker_id |
str | None
|
The worker instance that ran its final attempt; |
Source code in jasil/jobs/schema.py
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 | |
DurationBuckets ¶
Bases: BaseModel
How long completed attempts took, in fixed ranges.
The boundaries are part of this schema, so two deployments' dashboards are
comparable. Counts are non-cumulative and sum to the completed attempts in
the window. Hosts needing other boundaries or true percentiles compute them
from the duration_ms log field.
Attributes:
| Name | Type | Description |
|---|---|---|
under_100ms |
int
|
|
under_1s |
int
|
|
under_10s |
int
|
|
under_1m |
int
|
|
under_5m |
int
|
|
over_5m |
int
|
|
Source code in jasil/jobs/schema.py
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | |
EventJobs ¶
Bases: BaseModel
Every subscriber's job for one event, each with its attempts.
Answers "which worker processed this event, for each subscriber?". An event's
event_log row only records publication, so this is where its outcome lives.
Source code in jasil/jobs/schema.py
255 256 257 258 259 260 261 262 263 264 | |
EventLogFailure ¶
Bases: BaseModel
A single failed or dead-lettered event for inspection.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The event_id. |
event_type |
str
|
The domain-event channel. |
event_source |
str
|
Where the event originated. |
handler_name |
str | None
|
The subscriber(s) that processed the event. |
error_message |
str | None
|
The failure reason. |
retry_count |
int
|
Processing attempts so far. |
event_metadata |
dict[str, Any] | None
|
Correlation context (request_id, plus any host-defined keys). |
created_at |
datetime
|
When the event was published. |
completed_at |
datetime | None
|
When processing finished. |
Source code in jasil/event_log/schema.py
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 | |
EventLogPending ¶
Bases: BaseModel
A group of not-yet-finished events and its oldest age.
Attributes:
| Name | Type | Description |
|---|---|---|
event_type |
str
|
The domain-event channel. |
status |
str
|
The pending state (published or processing). |
count |
int
|
Number of events in this group. |
oldest_seconds |
float | None
|
Age of the oldest event in the group, in seconds. |
Source code in jasil/event_log/schema.py
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | |
EventLogSummary ¶
Bases: BaseModel
The full admin-dashboard payload, aggregated from event_log.
Attributes:
| Name | Type | Description |
|---|---|---|
window_hours |
int
|
The look-back window applied to the aggregates. |
total_events |
int
|
Total events recorded within the window. |
by_type |
list[EventTypeStats]
|
Per-event-type throughput/outcome/latency stats. |
pending |
list[EventLogPending]
|
Not-yet-finished event groups and their oldest age. |
recent_failures |
list[EventLogFailure]
|
The most recent failed/dead-lettered events. |
Source code in jasil/event_log/schema.py
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
EventTypeStats ¶
Bases: BaseModel
Per-event-type throughput, outcome, and latency counts.
Attributes:
| Name | Type | Description |
|---|---|---|
event_type |
str
|
The domain-event channel. |
total |
int
|
Total events of this type in the window. |
published |
int
|
Count still in the published state. |
queued |
int
|
Count handed to the durable job queue (terminal in event_log; execution is tracked per-subscriber in the Jobs dashboard). |
processing |
int
|
Count currently processing. |
completed |
int
|
Count that finished successfully. |
failed |
int
|
Count that failed. |
dead_letter |
int
|
Count moved to dead-letter. |
avg_processing_time_ms |
float | None
|
Mean handler time, or None when unmeasured. |
max_processing_time_ms |
int | None
|
Slowest handler time, or None. |
Source code in jasil/event_log/schema.py
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | |
JobAttemptInfo ¶
Bases: BaseModel
One attempt to run a durable job.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The attempt id. |
job_id |
str
|
The job it ran. |
event_id |
str
|
The originating event. |
event_type |
str
|
The event channel. |
subscriber_id |
str
|
The subscriber that ran. |
queue |
str
|
The queue the job was claimed from. |
attempt |
int
|
The attempt number at claim. |
worker_id |
str
|
The worker instance that claimed it. |
claimed_at |
datetime
|
When the worker took the lease. |
started_at |
datetime | None
|
When the handler began; |
finished_at |
datetime | None
|
When the attempt ended; |
outcome |
str | None
|
One of |
duration_ms |
int | None
|
Handler wall time. |
error |
str | None
|
The failure reason, if it failed. |
Source code in jasil/jobs/schema.py
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 | |
JobDetail ¶
Bases: BaseModel
One job and its attempt history, newest attempt first.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
The job id. |
event_id |
str
|
The originating event. |
event_type |
str
|
The event channel. |
subscriber_id |
str
|
The durable subscriber. |
queue |
str
|
The named queue. |
source |
str
|
Where the event came from. |
status |
str
|
|
attempts |
int
|
Attempts made so far. |
max_attempts |
int
|
The attempt ceiling. |
locked_by |
str | None
|
The worker holding it now, while claimed. |
last_error |
str | None
|
The most recent failure reason. |
available_at |
datetime
|
Earliest instant it may be claimed. |
lease_expires_at |
datetime | None
|
When the current lease expires, while claimed. |
created_at |
datetime
|
When it was enqueued. |
updated_at |
datetime
|
When it last changed state. |
completed_at |
datetime | None
|
When it reached a terminal state. |
history |
list[JobAttemptInfo]
|
Its attempts, newest first, bounded by the caller's limit. |
attempts_recorded |
bool
|
Whether this process records attempts. |
Source code in jasil/jobs/schema.py
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 251 252 | |
JobQueueStats ¶
Bases: BaseModel
Windowed job counts and all-time current backlog age for one named queue.
Source code in jasil/jobs/schema.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 | |
JobReplayResult ¶
Bases: BaseModel
The outcome of replaying a dead-lettered job.
Attributes:
| Name | Type | Description |
|---|---|---|
replayed |
bool
|
True when the job was requeued for a fresh run. |
Source code in jasil/jobs/schema.py
151 152 153 154 155 156 157 158 159 | |
JobSubscriberStats ¶
Bases: BaseModel
Per-subscriber job counts by status within the window.
Attributes:
| Name | Type | Description |
|---|---|---|
subscriber_id |
str
|
The durable subscriber. |
event_type |
str
|
The event channel it reacts to. |
total |
int
|
Total jobs for this subscriber in the window. |
pending |
int
|
Count waiting to be claimed (includes backoff). |
claimed |
int
|
Count currently leased by a worker. |
completed |
int
|
Count that finished successfully. |
dead_letter |
int
|
Count that exhausted retries. |
avg_duration_ms |
float | None
|
Mean handler time of attempts that completed in the
window; |
max_duration_ms |
int | None
|
Slowest such attempt. |
duration_buckets |
DurationBuckets | None
|
The same attempts in fixed duration ranges; |
superseded |
int | None
|
Attempts that ended in the window by finishing after their
job was taken (the handler ran twice); |
Source code in jasil/jobs/schema.py
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 | |
JobsSummary ¶
Bases: BaseModel
The durable-jobs admin-dashboard payload.
Attributes:
| Name | Type | Description |
|---|---|---|
window_hours |
int
|
The look-back window applied to the counts. |
total_jobs |
int
|
Total jobs enqueued within the window. |
pending |
int
|
Window count waiting to be claimed. |
claimed |
int
|
Window count currently leased. |
completed |
int
|
Window count finished successfully. |
dead_letter |
int
|
Window count that exhausted retries. |
oldest_pending_seconds |
float | None
|
Age of the oldest unfinished job, in seconds. |
by_subscriber |
list[JobSubscriberStats]
|
Per-subscriber breakdown within the window. |
by_queue |
list[JobQueueStats]
|
Per-queue breakdown within the window. |
recent_dead_letter |
list[DeadLetterJob]
|
The current dead-letter queue contents (most recent first). |
Source code in jasil/jobs/schema.py
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 | |
WorkerAttempts ¶
Bases: BaseModel
One bounded page of a worker instance's attempts, newest first.
Source code in jasil/jobs/schema.py
267 268 269 270 271 272 273 | |
WorkerInfo ¶
Bases: BaseModel
Operator-facing state for one restart-unique worker instance.
The completed .. abandoned counts are attempts that ended within
the summary's window, and are None when attempts are not recorded.
Source code in jasil/jobs/schema.py
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 | |
WorkersSummary ¶
Bases: BaseModel
Global worker totals and one bounded page of retained telemetry.
Source code in jasil/jobs/schema.py
303 304 305 306 307 308 309 310 311 312 313 314 | |
get_event_jobs ¶
get_event_jobs(event_id, *, attempt_limit=20)
Return every subscriber's job for one event, each with its attempts.
Answers "which worker processed this event, for each subscriber?". An event's
event_log row records its publication only; a durable event's outcome is
the state of these jobs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_id
|
str
|
The event id. |
required |
attempt_limit
|
int
|
Most attempts to return per job, from 1 through 500. |
20
|
Returns:
| Type | Description |
|---|---|
EventJobs
|
The event's jobs (at most 200), oldest first; empty when the event has |
EventJobs
|
none. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/admin.py
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 | |
get_event_log_summary ¶
get_event_log_summary(*, hours=24, failure_limit=20)
Aggregate event_log into the observability dashboard payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hours
|
int
|
Look-back window for the throughput, outcome, and latency stats. |
24
|
failure_limit
|
int
|
Maximum recent failures to return. |
20
|
Returns:
| Type | Description |
|---|---|
EventLogSummary
|
Per-event-type statistics, the not-yet-finished groups and their oldest |
EventLogSummary
|
age, and the most recent failures. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When |
Source code in jasil/admin.py
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | |
get_job ¶
get_job(job_id, *, attempt_limit=50)
Return one job and its attempt history, newest attempt first.
Answers "what happened to this job?": which worker ran each attempt, how long it took, how it ended, and why it failed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
job_id
|
str
|
The job id (as shown on a dead letter). |
required |
attempt_limit
|
int
|
Most attempts to return, from 1 through 500. |
50
|
Returns:
| Type | Description |
|---|---|
JobDetail | None
|
The job with its history, or |
JobDetail | None
|
|
JobDetail | None
|
earlier and |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/admin.py
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 | |
get_jobs_summary ¶
get_jobs_summary(*, hours=24, dead_letter_limit=50)
Aggregate processing_jobs into the durable-jobs dashboard payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hours
|
int
|
Look-back window for the status and per-subscriber counts. |
24
|
dead_letter_limit
|
int
|
Maximum dead-letter jobs to return for inspection. |
50
|
Returns:
| Type | Description |
|---|---|
JobsSummary
|
Window counts, the per-subscriber breakdown, the age of the oldest |
JobsSummary
|
unfinished job, and the current dead-letter queue. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When |
Source code in jasil/admin.py
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | |
get_worker_attempts ¶
get_worker_attempts(instance_id, *, limit=100, cursor=None)
Return one page of a worker instance's attempts, newest first.
Answers "what has this worker been doing, and how did it go?".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instance_id
|
str
|
The worker instance id ( |
required |
limit
|
int
|
Page size from 1 through 500. |
100
|
cursor
|
str | None
|
Opaque cursor returned as |
None
|
Returns:
| Type | Description |
|---|---|
WorkerAttempts
|
The page of attempts; empty for an unknown worker. |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in jasil/admin.py
231 232 233 234 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 | |
get_workers_summary ¶
get_workers_summary(
*,
stale_after_seconds=None,
limit=100,
cursor=None,
hours=24,
)
Return global worker totals and one bounded telemetry page.
Status is operator telemetry only: JASIL does not map it onto a host health endpoint or choose an alert policy.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stale_after_seconds
|
float | None
|
Maximum heartbeat age still considered running. Defaults to three configured heartbeat intervals. |
None
|
limit
|
int
|
Page size from 1 through 500. |
100
|
cursor
|
str | None
|
Opaque cursor returned as |
None
|
hours
|
int
|
Look-back window for each worker's attempt counts (attempts that
ended within it) and |
24
|
Returns:
| Type | Description |
|---|---|
WorkersSummary
|
Worker counts and retained worker-instance details. The attempt counts |
WorkersSummary
|
are |
WorkersSummary
|
|
WorkersSummary
|
worker that stopped beating. |
Source code in jasil/admin.py
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 | |
replay_dead_letter_job ¶
replay_dead_letter_job(job_id)
Requeue a dead-lettered job for a fresh run with a full attempt budget.
Only a job currently in dead_letter is affected, so replaying one twice
is harmless: the second call finds it pending and reports False.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
job_id
|
str
|
The job to replay. |
required |
Returns:
| Type | Description |
|---|---|
JobReplayResult
|
Whether a dead-letter job was requeued. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
When no platform has been published, or when
|
Source code in jasil/admin.py
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | |
Test helpers¶
jasil.testing ¶
Test helpers for applications that embed JASIL.
JASIL installs several things process-wide — settings, a correlation provider, a session factory, the active platform, memoized Redis clients, the durable subscriber registry — and a test suite has to put every one of them back between cases or state leaks from one test into the next. This module is the shared version of the fixture every host would otherwise write.
Nothing here is imported by the library itself, and nothing here needs an optional extra::
import jasil.testing as jasil_testing
@pytest.fixture(autouse=True)
def _jasil(tmp_path):
platform = jasil_testing.install_test_platform(tmp_path)
yield platform
jasil_testing.reset_all()
FixedClock
dataclass
¶
A ClockProvider whose time only moves when a test moves it.
Lease expiry, retry backoff, and retention windows are all measured against the platform clock, so injecting this is what lets a suite exercise them without sleeping.
monotonic advances in lockstep with now rather than tracking the real
clock, so code measuring an elapsed duration sees the time the test moved.
Attributes:
| Name | Type | Description |
|---|---|---|
moment |
datetime
|
The instant :meth: |
Source code in jasil/testing.py
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 | |
advance ¶
advance(seconds)
Move time forward.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seconds
|
float
|
How far to advance. Negative values move time backwards, which is occasionally what a clock-skew test wants. |
required |
Source code in jasil/testing.py
66 67 68 69 70 71 72 73 | |
monotonic ¶
monotonic()
Return a monotonic reading derived from :attr:moment.
Source code in jasil/testing.py
62 63 64 | |
now ¶
now()
Return the current instant.
Source code in jasil/testing.py
58 59 60 | |
MemoryMail ¶
MailProvider that appends each message to :attr:outbox.
Messages are validated and given a real Message-ID by the same builder
the SMTP backend uses, so a test fails on the same invalid message
production would reject.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
clock
|
ClockProvider
|
Time source for the |
required |
from_address
|
str
|
Sender identity; a placeholder when empty. |
''
|
Source code in jasil/backends/mail_memory.py
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 | |
clear ¶
clear()
Forget every recorded message, e.g. between two phases of a test.
Source code in jasil/backends/mail_memory.py
81 82 83 84 85 | |
send ¶
send(message)
Validate message, record it, and return its Message-ID.
Raises:
| Type | Description |
|---|---|
ValueError
|
When the message is invalid. |
MailError
|
When the durable job sending it has lost its lease, so a
worker that no longer owns the job records nothing, as
:class: |
Source code in jasil/backends/mail_memory.py
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
install_test_platform ¶
install_test_platform(
data_dir, *, clock=None, settings=None
)
Build an all-in-process platform, publish it, and return it.
The local profile already resolves every capability to a process-local
backend, so this needs no infrastructure. What it adds is the part that is
easy to get wrong: rooting local storage inside the test's temporary
directory instead of the working directory, substituting a controllable
clock, and publishing the result — a platform that is built but never
passed to set_active_platform makes every publish raise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data_dir
|
str | Path
|
Directory the local storage backend writes under. Pass pytest's
|
required |
clock
|
FixedClock | None
|
Time source to install. Defaults to a fresh :class: |
None
|
settings
|
JasilSettings | None
|
Base configuration to build from. Defaults to an all-defaults
:class: |
None
|
Returns:
| Type | Description |
|---|---|
Platform
|
The published :class: |
Source code in jasil/testing.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 117 118 119 120 121 122 | |
memory_mail ¶
memory_mail(platform)
Return the recording mail backend of a test platform, typed.
Platform.mail is a :class:~jasil.providers.MailProvider, which has no
outbox; this narrows it for a type checker and fails loudly when the
platform was built with a real backend.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
platform
|
Platform
|
The platform from :func: |
required |
Returns:
| Type | Description |
|---|---|
MemoryMail
|
The |
Raises:
| Type | Description |
|---|---|
TypeError
|
When |
Source code in jasil/testing.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
reset_all ¶
reset_all()
Clear every process-wide slot JASIL installs, except the ORM mapping.
Covers the settings, the correlation provider, the published platform, the durable subscriber registry, and the memoized Redis clients. Call it in fixture teardown; leaving any one of them set is how a passing test starts depending on the one before it.
The declarative base is deliberately left alone. JASIL's model modules
capture it at import time, so clearing it would strand every model already
imported — and a test module that imports one at module scope cannot re-import
it. Map once for the whole session and leave it mapped. Call
:func:jasil.orm.reset yourself only if you genuinely need to remap.
Does not close the platform: call :meth:jasil.container.Platform.close
first if this process opened real connections.
Source code in jasil/testing.py
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | |
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.adopt_existing_schema(engine) # validate/record legacy tables, if any
migrations.upgrade(engine) # create/upgrade JASIL's tables
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.
SchemaCompatibilityError ¶
Bases: RuntimeError
An unversioned JASIL schema cannot safely be adopted.
Source code in jasil/migrations/__init__.py
60 61 | |
adopt_existing_schema ¶
adopt_existing_schema(engine)
Validate and adopt complete, unversioned JASIL tables.
The tables are matched exactly against the head schema, then against each
earlier revision's schema, newest first. The first exact match is recorded
as the database's revision, so a following upgrade() applies whatever
revisions come after it. If two revisions describe the same schema, the
newest is recorded. An older revision's schema is derived from the
migrations' own downgrade functions, never maintained separately.
Returns True only when this call records a revision. An empty database
or one that already has a JASIL revision is left unchanged and returns
False.
Raises:
| Type | Description |
|---|---|
SchemaCompatibilityError
|
If no revision matches exactly; the message names the closest revision and lists its differences. |
Source code in jasil/migrations/__init__.py
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | |
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
116 117 118 | |
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
64 65 66 67 68 69 70 71 72 73 74 | |
upgrade ¶
upgrade(engine, revision='head')
Create or upgrade JASIL's tables to revision (default head).
Source code in jasil/migrations/__init__.py
111 112 113 | |
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
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | |