0029. Locks, Read Models, and the Engine Factory: Completing the Ring Migration¶
The six remaining pre-ring modules -- locks/, readmodels/, and top-level
engine.py -- move onto the ports/adapters split ADR 0019, ADR 0024, and
ADR 0026 already applied to the store, checkpoint/DLQ, and outbox surfaces.
locks/ splits into a Tier 0 Protocol pair plus a PostgreSQL adapter and an
in-memory test double; readmodels/ splits into a Tier 0 subpackage plus
three backend adapters; engine.py relocates to adapters/_sql/engine.py
with no ring split, since it was never a Protocol/implementation pair to
begin with -- only a placement decision. Both eventsource.locks and
eventsource.readmodels remain importable as deprecated lazy shims, removed
in 0.8.0.
Status¶
Accepted. Implemented in src/eventsource/ports/locks.py,
src/eventsource/adapters/memory/locks.py,
src/eventsource/adapters/postgresql/locks.py,
src/eventsource/ports/readmodels/ (model.py, query.py, repository.py,
exceptions.py), src/eventsource/adapters/memory/readmodels.py,
src/eventsource/adapters/postgresql/readmodels.py,
src/eventsource/adapters/sqlite/readmodels.py,
src/eventsource/adapters/sql/readmodel_schema.py,
src/eventsource/adapters/sql/readmodel_projection.py, and
src/eventsource/adapters/_sql/engine.py. Conformance suites:
DistributedLockConformance and ReadModelRepositoryConformance in
src/eventsource/testing/conformance_ports/.
Amended by ADR 0030: the
eventsource.locks and eventsource.readmodels deprecation shims described
below were removed ahead of the 0.8.0 schedule this ADR originally set,
as part of a pre-1.0 decision to stop carrying deprecation shims at all.
import eventsource.locks / import eventsource.readmodels now raise
ModuleNotFoundError; the ports/adapters split this ADR made is otherwise
unaffected.
Amended by ADR 0050: the
read-model OptimisticLockError this ADR placed in
ports/readmodels/exceptions.py -- and whose name collision with the domain
exception it recorded as a knowingly-deferred consequence -- is renamed
ReadModelVersionConflictError. The subpackage split this ADR decided is
otherwise unaffected.
Amends ADR 0023. ADR 0023's Decision
does not change and is not retro-edited: PostgreSQLLockManager remains the
only production distributed-lock implementation, and its "PostgreSQL-only"
scope stands exactly as ADR 0023 argued it. What this ADR adds is a
ports/locks.py Protocol pair (DistributedLock, LockRegistry) that ADR
0023 did not anticipate needing. The amendment is narrow and is worth stating
precisely, because it is easy to over-read: the port describes the shape of
the dependency, not an equivalence of distributed semantics. DistributedLock
says "something with acquire/try_acquire/release/is_held"; it says
nothing about cross-process exclusion, release on crash, or fairness. Those
guarantees are PostgreSQLLockManager's alone, and ADR 0023's "When Not to
Use This" section -- and its single-primitive argument generally -- stands
untouched. InMemoryLockManager, the second conforming implementation this
ADR introduces, is a conforming implementation of the narrower Protocol
contract, restricted by its own docstring to single-process testing. ADR 0023
is not superseded; nothing about advisory locks as the production
mechanism changes.
Sibling of ADR 0024 and ADR 0026 -- the same Protocol/implementation split, applied to the last two pre-ring packages.
Context¶
Four modules were left over from before the ring migration began: locks/
and readmodels/ each mixed a Protocol (or a Protocol-shaped contract) with
its concrete implementations in the same package, the pattern ADR 0019, ADR
0024, and ADR 0026 already fixed for the store, checkpoint/DLQ, and outbox
surfaces; and top-level engine.py, which was never a Protocol/implementation
pair but nonetheless predated the ring structure and had no assigned ring.
docs/core-surface.md tracked all of these as open items, and BACKLOG.md
carried three "campaign residue" entries naming them directly: "Migrate
locks/ to ports/adapters," "Split readmodels/ into port + adapter," and
"Decide engine.py's ring placement."
locks/ bundled PostgreSQLLockManager, LockInfo, migration_lock_key,
and the two lock exceptions in one package, with no Protocol anywhere --
MigrationCoordinator and CutoverManager annotated their lock_manager
parameters as the concrete PostgreSQLLockManager class because there was
nothing narrower to type against, and there was no way to write a fake lock
manager for a unit test short of hand-rolling one to the concrete class's
shape with no contract to check it against.
readmodels/ already had its Tier 0 and non-Tier-0 halves in separate files
(base.py, query.py, schema.py, repository.py, in_memory.py,
exceptions.py on one side; postgresql.py, sqlite.py, projection.py on
the other) -- docs/core-surface.md finding 5 described the split as "already
made for it," needing only the package __init__ to stop eagerly
re-exporting the backend half. readmodels/postgresql.py additionally
reached into adapters/_sql/connection.py for sql_connection across 16
call sites, a non-adapters/ module importing from adapters/ that ADR 0026
§4 recorded as accepted debt, resolved when the module itself moved under
adapters/.
engine.py held create_async_engine and SQLITE_PRAGMAS: a factory that
constructs a SQLAlchemy AsyncEngine and registers dialect-specific
@event.listens_for driver listeners for SQLite transaction control. It sat
at the top level of the package, imported eagerly by eventsource/__init__.py
as part of the sqlalchemy-on-import chain docs/core-surface.md finding 12
tracks. Unlike locks/ and readmodels/, it was never a Protocol paired with
implementations -- there is exactly one create_async_engine, used by every
backend that needs a SQLAlchemy engine. The only question was where a
sqlalchemy-backed leaf module with no port of its own belongs.
Decision¶
1. locks/ splits along its two real consumer groups (ISP)¶
ports/locks.py defines two small Protocols rather than one wide interface,
because the library's actual callers split cleanly along a capability
boundary:
DistributedLock--acquire,try_acquire,release,is_held. Whatmigration/cutover.pyandmigration/coordinator.pyneed: acquire and release individual locks.LockRegistry--release_all,held_lock_count. Bulk lifecycle over everything one manager instance holds -- a shutdown-and-introspection concern for whoever owns the manager, not something a caller acquiring a single lock needs to see.
PostgreSQLLockManager (now adapters/postgresql/locks.py) and
InMemoryLockManager (adapters/memory/locks.py, new) both implement both
Protocols, so the split costs implementers nothing; it exists so a consumer
that only acquires locks can type against DistributedLock without pulling
in release_all/held_lock_count as part of its contract.
acquire is typed as returning AbstractAsyncContextManager[LockInfo]
rather than declared async def acquire(...) -> AsyncIterator[LockInfo].
Both implementations decorate their acquire method with
@asynccontextmanager, and the runtime type @asynccontextmanager
produces is exactly AbstractAsyncContextManager, not an async generator --
declaring the Protocol method as async def ... -> AsyncIterator[...] would
describe the undecorated function body, not the object callers actually
receive, and would fail to type-check against either implementation.
InMemoryLockManager is a genuinely new implementation, not a relocation.
Its docstring leads with what it does not guarantee, and that ordering is
deliberate: no cross-process exclusion (a dict guarded by an
asyncio.Condition, not a database primitive); no release on crash (no
server session to drop when the holder disappears); no fairness (waiters
wake in whatever order asyncio.Condition happens to pick, no FIFO queue);
single event loop, single process. It exists so unit tests can exercise
code written against DistributedLock without a PostgreSQL instance, and for
nothing else -- reaching for it anywhere a real cross-process guarantee is
needed is a misuse the docstring is written to head off before the API
surface invites it.
2. Exception rebasing: LockAcquisitionError / LockNotHeldError onto EventSourceError¶
Recorded exception (b). LockAcquisitionError and LockNotHeldError
move to eventsource.exceptions and now subclass EventSourceError; they
previously derived directly from Exception, defined in
eventsource/locks/postgresql.py. This is the one semantic change in the
whole slice, and it is widening only: every existing
except LockAcquisitionError and except Exception still catches exactly as
before. The newly-catching clause is except EventSourceError, which caught
nothing lock-related prior to this change. The rebasing is motivated by
consistency, not by a defect -- there was no principled reason for the lock
exceptions to be the one library-raised error family outside the common base,
and application code that wants one boundary handler for "an eventsource
operation failed" can now write it without special-casing locks.
3. readmodels/ becomes a ports/readmodels/ subpackage, not a flat module¶
The rejected alternatives matter here as much as the decision, because both were seriously considered:
Rejected: flat ports/readmodels.py. A single module mirroring
ports/outbox.py or ports/dlq.py's shape. Rejected because readmodels/
is not one contract the way outbox or DLQ are -- it is four genuinely
distinct pure artifacts users reach for independently: a user-subclassable
pydantic base (ReadModel), a query specification language (Query,
Filter), a 15-method repository Protocol (ReadModelRepository), and an
exception family. Flattening them into one module would not reduce anything
real; it would just make a 400+-line file out of four files that are already
cleanly separated and already have independent reasons to change.
Rejected: domain/readmodel.py for ReadModel. Considered because
ReadModel is, in isolation, a plausible domain concept -- a pydantic base
class a user subclasses, much like DomainEvent. Rejected because
ReadModel is not a domain concept in the ring-architecture sense: it is the
persistence contract's payload shape, inseparable from Query/Filter
(which specify how it is queried) and ReadModelRepository (which specifies
how it is stored and retrieved). Splitting ReadModel into domain/ while
its query and repository contract stayed in ports/ would put one concept's
three faces in two rings for no boundary-clarifying reason -- the dependency
rule does not require it, since nothing about ReadModel reaches outward,
and colocating it with what it composes with reads more honestly than
colocating it with DomainEvent, which it is not.
Decision: a subpackage. ports/readmodels/ holds model.py (ReadModel),
query.py (Query, Filter), repository.py (ReadModelRepository,
ReadModelRepositoryProtocol), and exceptions.py (ReadModelError,
OptimisticLockError, ReadModelNotFoundError). eventsource.ports.readmodels
is the one import path users see regardless of which of the four they want;
the submodule layout is an implementation detail, the same convention the
package docstring states explicitly.
The adapter half lands under eventsource.adapters.{memory,postgresql,sqlite,
sql}: InMemoryReadModelRepository in adapters/memory/readmodels.py,
PostgreSQLReadModelRepository in adapters/postgresql/readmodels.py,
SQLiteReadModelRepository in adapters/sqlite/readmodels.py.
readmodels/postgresql.py's 16 sql_connection call sites now live inside
adapters/, resolving the accepted debt ADR 0026 §4 recorded against them --
there is no longer a non-adapters/ module reaching into adapters/ for a
connection helper on this path.
4. schema.py moves to adapters/sql/readmodel_schema.py, and loses its Tier-0 sqlalchemy guard by design¶
generate_schema, generate_indexes, generate_full_schema,
POSTGRESQL_TYPE_MAP, and SQLITE_TYPE_MAP move from readmodels/schema.py
to adapters/sql/readmodel_schema.py. Before this move, docs/core-surface.md
tracked the module as Tier 0 -- pure Python, no sqlalchemy import, generating
CREATE TABLE / CREATE INDEX text as plain strings. That was true and is
still true of the module's imports. It was the wrong thing to track,
though: the module hardcodes POSTGRESQL_TYPE_MAP and SQLITE_TYPE_MAP and
emits dialect-specific DDL text, which is dialect knowledge -- an adapter
concern -- regardless of whether the code happens to avoid importing the
sqlalchemy package to do it. The "must not import sqlalchemy" guard was
asserting a property (no sqlalchemy dependency) that happened to be true but
was never the property that actually mattered (does this module encode
backend-specific knowledge). Once the question was asked correctly, the
answer moved the module out of Tier 0's import-linter contract by design, not
by accident -- see the pyproject.toml comment beside the Tier 0 contract for
the same point stated at the enforcement layer.
ReadModelProjection (from readmodels/projection.py) moves to
adapters/sql/readmodel_projection.py, alongside DatabaseProjection, which
it already depended on module-level -- the same non-Tier-0 status it already
had, now correctly colocated with the SQL adapter it extends rather than
living in a package that also hosted Tier 0 contract code.
5. engine.py moves to adapters/_sql/engine.py¶
Two rejected alternatives, both narrower moves that were considered and set aside:
Rejected: dissolve into the existing connection helpers. engine.py
could have been split across adapters/_sql/connection.py and
adapters/_sql/dialect.py, on the theory that a single-function module is a
seam not worth keeping. Rejected because create_async_engine is not a
connection-normalization helper or a dialect-parameterization helper -- it is
a distinct concern (constructing the AsyncEngine itself, plus SQLite
transaction-control and PRAGMA setup via @event.listens_for listeners) that
both of those modules currently need, not one they should absorb. Merging it
in would make either module do two unrelated jobs for no reduction in moving
parts.
Rejected: adapters/sql/engine.py. The narrower question was not
whether to move it into adapters/, but which adapters/sql* package.
adapters/sql/__init__.py eagerly imports checkpoints, dlq, and
projection, and projection (adapters/sql/projection.py) reaches into
application/projections/. Placing a leaf engine factory in adapters/sql/
would drag the application-ring projection machinery into whatever imports
create_async_engine -- exactly the front-door import-chain widening
docs/core-surface.md finding 12 exists to narrow, not grow.
Decision: adapters/_sql/engine.py. adapters/_sql/__init__.py is
import-free -- it already hosts connection.py, dialect.py, and
positions.py on that basis -- so placing engine.py there costs nothing
beyond what importing the module itself costs. eventsource.create_async_engine,
the public name, is unchanged in signature and behavior; only the module path
changed, and the module path was never the advertised import surface (nothing
in the docs told users to import from eventsource.engine directly). The
front-door import chain moves one module deeper --
eventsource/__init__ -> adapters/_sql/engine -> sqlalchemy -- but does
not get cheaper or more expensive: adapters/_sql/engine.py's own import
block is identical to engine.py's, unchanged by the move.
6. Recorded exception (a): the read-model exception trio stays out of exceptions.py¶
ReadModelError, OptimisticLockError (the read-model one), and
ReadModelNotFoundError remain in ports/readmodels/exceptions.py rather
than joining the central eventsource.exceptions module the way
LockAcquisitionError/LockNotHeldError did. The reason is the
name collision this ADR does not resolve: eventsource.exceptions already
defines OptimisticLockError for aggregate/event-store version conflicts,
with a different constructor signature (aggregate_id, expected_version,
actual_version versus the read-model one's model_id, expected_version,
actual_version=None) and no inheritance relationship between the two.
Moving the read-model exceptions into eventsource.exceptions today would
either silently shadow the existing name or require resolving the collision
as a side effect of an unrelated ring-placement task -- neither is
acceptable. This collision predates this slice; it is not introduced by
the ring migration, and BACKLOG.md's "Resolve the duplicate
OptimisticLockError name (readmodels vs core)" entry (P2) is the follow-up
that names the fix (rename the read-model class to
ReadModelVersionConflictError with a deprecation alias) and tracks it
explicitly as pre-existing.
7. Deprecation shims: eventsource.locks and eventsource.readmodels¶
Both packages become lazy re-export shims. eventsource/locks/__init__.py
resolves LockInfo, migration_lock_key (from ports.locks),
LockAcquisitionError, LockNotHeldError (from exceptions), and
PostgreSQLLockManager (from adapters.postgresql.locks).
eventsource/readmodels/__init__.py resolves all sixteen names the old
package exported, split across ports.readmodels and the three adapter
modules plus adapters.sql.readmodel_schema/readmodel_projection. Each
__getattr__ emits a DeprecationWarning naming the specific new import
path for the attribute being read, and each module defines __dir__
alongside __getattr__ -- a lazy __getattr__ without __dir__ broke
introspection (dir(module), tab completion, some pytest collection
paths) during the snapshots migration and had to be fixed after the fact;
both shims here ship with the fix already in place. Both packages are
removed in 0.8.0, tracked in BACKLOG.md.
Consequences¶
Positive.
ports/locks.pyandports/readmodels/are genuinely Tier 0: no sqlalchemy, importable in a bare interpreter.docs/core-surface.md's Tier 0 boundary now covers two more subsystems that used to taint an entire package on import.- Code that only needs to acquire a lock can type against
DistributedLockand be tested againstInMemoryLockManager, with no PostgreSQL dependency in the test suite for that code path. ReadModelError's three exceptions,ReadModel,Query, andFilterare importable without pulling in any of the three backend adapters -- a consumer writing pure query-construction code no longer needs sqlalchemy or aiosqlite on the import path.- The accepted debt ADR 0026 §4 recorded against
readmodels/postgresql.pyis resolved for that one module;migration/repositories/*.py's four modules remain the open remainder, tracked separately.
Negative / accepted.
- The
OptimisticLockErrorname collision is not fixed by this slice, only documented and tracked. Two unrelated classes with the same name acrosseventsource.exceptionsandeventsource.ports.readmodels.exceptionsremains a trap for anyone importing both into the same scope. InMemoryLockManageradds a second lock implementation to maintain, with no production use -- pure test-support surface area, justified only by what it buys for the Protocol split's usability.- Front-door import time for
eventsourceis unchanged in cost, not reduced: the engine move relocates the sqlalchemy entry point, it does not remove it.BACKLOG.md's "Lazy top-level eventsource/init" entry is updated to reflect the new one-module-deeper chain, but stays out of this slice's scope. - Two deprecated packages now carry runtime warning overhead until 0.8.0.
Existing code that imports from
eventsource.locksoreventsource.readmodelscontinues to work but emits aDeprecationWarningper attribute access, which a-W error::DeprecationWarningtest configuration will surface as failures until callers migrate.
Alternatives Considered¶
Leave locks/ and readmodels/ as-is, since only one implementation
exists for each. Considered and rejected on the same grounds ADR 0024 and
ADR 0026 already rejected it for checkpoints/DLQ and outbox: a single
implementation today does not mean the Protocol/implementation split is
premature -- it means the split has been deferred, and every module in the
ring migration so far had exactly this shape (one production backend, a
contract worth naming anyway for the ports Tier 0 boundary and for testing).
The pattern established by five prior ADRs was reason enough to complete it
for the two remaining packages rather than leave docs/core-surface.md
recording two more open items indefinitely.
Fold the engine-placement decision into a larger adapters/_sql/
reorganization. Considered and rejected as scope creep: engine.py's move
is a pure relocation with a two-sentence rationale, not a redesign, and
bundling it with unrelated adapters/_sql/ restructuring would have made a
mechanical change harder to review for no benefit.