v0.1.0
First release. Extracted from Endurain, where this code ran as an internal infra package. The API is still settling: 0.x releases may break it, and the SemVer guarantees in API stability begin at 1.0.0.
Added
Capability providers and backends
StateProvider— ephemeral keyed state with TTLs, backed by process-local memory (memory://) or Redis (redis:///rediss:///unix://). Beyond plain key/value access it exposes the atomic primitives correctness actually depends on —set_if_absent,get_and_delete, andrecord_tiered_failure(an atomic tiered lockout, implemented under a lock in memory and as a Lua script on Redis) — so a store behaves identically on either backend.StorageProvider— opaque blob storage addressed by(area, key), backed by the local filesystem (local://) or S3-compatible object storage (s3://). Both backends refuse the same addresses — empty, absolute, or containing a..component — before touching a disk or a client, so the contract a caller sees does not change with the deployment.list_keysis recursive on both, so a nested key is listed wherever it is stored.EventBusProvider— synchronous in-process dispatch (memory://) or Redis Streams with a consumer group (redis://), giving competing-consumer semantics across replicas.LockProvider— a no-op lock (noop://) or PostgreSQL session-level advisory locks (postgres-advisory://), which need no infrastructure beyond the database the host already has.ClockProviderandGeocodingProvider, the latter covering Nominatim, Photon, and geocode.maps.co behind one interface.- A Redis outage surfaces to callers as
StateBackendUnavailableError, so domain code never imports or catches a redis-py exception.
Deployment profiles
local,distributed, andcustomprofiles. The profile supplies the default for each capability URI, and thelocalprofile is the zero-config default: memory state, local disk, in-process events, no-op lock.distributedandcustomrefuse to start when a capability URI is unset rather than falling back to a process-local backend, which across replicas would diverge silently — the failure the profile system exists to prevent.- A startup capability report showing how each capability resolved and why, plus consistency checks that reject a multi-process topology wired to process-local state.
build_platform()runs both before it constructs a single backend; setenforce_deployment_consistency=Falseto downgrade a refusal to a warning.
Events
- One immutable
Eventenvelope with an ISO-8601 UTC timestamp, a UUIDv4 id stable across retries, and a caller-ownedmetadatadict. event_id,event_typeandsourceare length-checked when the envelope is minted, andsubscriber_idwhen a durable subscriber registers, so a value too long for the column it is persisted in raises at the producing call site instead of failing at the write — where PostgreSQL and MySQL raise, SQLite does not, and the publish seam swallows the failure either way.payloadandmetadataare deliberately not capped; see the events documentation for what belongs in them.- Payload schema versioning:
VersionedPayloadcarries aSCHEMA_VERSIONand per-stepUPGRADERS. A payload written by an older build is walked forward one version at a time; one written by a newer build is refused rather than silently misread — the failure mode during a rolling deploy. publisher.publishis the single publish seam. Delivery failures are logged and swallowed so a publish never breaks the producer.publish_committing/publish_many_committingown the commit ordering, so durable delivery can be made atomic with the caller's domain write.subscribers.best_effortwraps a raising handler into a swallowing bus subscriber, so derived work can never fail the request that produced the event.
Durable jobs
- A transactional outbox relayed into leased per-subscriber jobs, with exponential backoff (equal jitter, to avoid a retry stampede), an attempt ceiling, and a dead-letter queue with replay.
- Idempotent consumers:
(event_id, subscriber_id)uniqueness is enforced by the database, so a re-delivered event never runs a subscriber twice. - Lease reclamation returns work stranded by a crashed worker; an attempt is counted at claim time, which is what bounds a crash loop.
- A worker's identity is bounded to the width of the lease column it is written to. When a long hostname forces truncation it carries a digest of the full value, so two machines sharing a hostname prefix never collapse onto one lease holder — which would hand each of them the other's claimed rows.
- On PostgreSQL, claiming and relaying use
FOR UPDATE SKIP LOCKEDso concurrent workers and relayers take disjoint batches with no coordinating lock. DurableSubscriberNetdeclares the reconciliation net — a scheduled backfill, or a documented exemption — that every subscriber writing durable derived state owes, since delivery is at-least-once but not guaranteed.assert_nets_completeholds the whole registry to it, so a missing net fails your own test suite rather than surfacing in production.
Observability
- An
event_logtable recording each event's lifecycle, written by the bus and the publish facade, with dashboard aggregates. - Identifiers that are too long for the column they are persisted in are refused at the producing call site; derived, diagnostic values (failure text, the joined subscriber list, a worker identity) are truncated with a marker instead, so a reader can tell the value was cut.
- Retention pruning in bounded batches, on independently configurable windows. In-flight rows and dead-letters are never pruned. Register it with
jasil.retention.schedule_retention_maintenance(scheduler), the counterpart ofjasil.jobs.service.schedule_job_maintenance— separate because retention also covers theevent_log, so it applies without durable jobs.
Host integration
- The host owns the declarative base and the engine. JASIL maps its tables into the host's registry via
map_models(Base)and takes a session factory viaconfigure_sessionmaker(...). JASIL never creates an engine.map_modelsmust run beforejasil.jobs.crudorjasil.event_log.crudis imported; every other public module —jasil.publisherabove all, which every producer imports at module scope — defers its model imports and is safe to import from anywhere in the host's import graph. jasil.settings— immutable, grouped configuration installed by the host. JASIL reads no environment variables and no secret files.jasil.correlation— a pluggable correlation-id provider, defaulting to a module-local context variable, so events carry the host's request id.- Packaged Alembic revisions (
jasil[migrations]) on their own version table,jasil_alembic_version, scoped to JASIL's tables so the host's Alembic history and tables are never touched. - FastAPI dependency helpers behind the
fastapiextra, resolvingapp.state.platformwhen the host attached one and otherwise the process-wide platform, so the quick-start wiring needs nothing extra. jasil.admin— the operator-facing surface:get_jobs_summary,get_event_log_summary, andreplay_dead_letter_job, plus the response schemas. Importable from anywhere in the host's import graph and takes no session, so an admin route cannot hand JASIL its own open transaction. The CRUD modules behind it stay internal.jasil.testing—FixedClock,install_test_platform, andreset_all, so a host's suite does not have to rediscover which process-wide slots JASIL installs.reset_alldeliberately leaves the ORM mapping in place; model modules capture the declarative base at import time.Platform.close()releases what the platform owns — the event-bus consumer thread and the shared Redis clients — and never raises, so a shutdown failure cannot mask whatever prompted the shutdown. The durable-job worker and the host's engine are stopped separately, because the platform does not own them.jasil.lifecycle.shutdown()composes the two halves of that in the order that matters — the worker stops before the bus its subscribers publish through — so a host does not have to know the ordering. Idempotent, safe before anything has started, and never raises.
Packaging
- A core install requires only
sqlalchemyandpydantic. Every backend client lives behind an extra (redis,s3,postgres,jobs,fastapi,geocoding,migrations,all) and is imported lazily, so a single-process deployment loads none of them. - Ships
py.typed.
Security
- SSRF guard on every outbound host JASIL dials on the host's behalf. All resolved addresses must be public unicast — a single private, loopback, link-local, or reserved answer rejects the host, which is what defends against DNS rebinding. An allowlist escape hatch exists for self-hosted services on private networks, and every use of it is logged for audit. Configured values must be a bare
host[:port], so one carrying a scheme or path cannot redirect a request elsewhere. An allowlist entry that is a hostname exempts every address that name resolves to, so taking one is logged atWARNINGnaming the address and recommending a CIDR; a CIDR exemption logs atINFO. - The geocoding backend refuses redirects, so a permitted host cannot 3xx-pivot onto an internal target. Its response body is read under a size cap, and its failures are logged by exception type and status code only:
requestsputs the request URL in an error message, and that URL carries the API key. - The Redis state backend escapes glob metacharacters before turning a caller's key prefix into a
SCAN/MATCHpattern, so a prefix holding*,?or[...]matches only itself and never widens onto keys the caller did not name. A prefix built from a tenant or user identifier is therefore safe. - The local storage backend rejects absolute and parent-traversal area and key values before any filesystem access, and percent-encodes both into the URL it returns, so a key holding
?,#or%cannot alter the URL it lands in.