Migrating to 0.17
From 0.16 to 0.17
Protean 0.17 makes the Unit of Work a real database transaction, begins consolidating the 1.0 public API surface, and retires the built-in email subsystem. The transaction change is the one to read before upgrading: it changes behaviour in code you did not touch. Renamed public options are drop-in: the deprecated spelling keeps working (behind a warning) through the transition window. One framework-internal option that was never part of the public API is removed outright, with no transition window. The email subsystem keeps working too, now behind a deprecation warning, and is scheduled for removal in v1.0.0.
On this page:
- The Unit of Work is a real transaction: read this first, nested blocks and external I/O behave differently
event_sourcedreplacesis_event_sourced— canonical boolean option spellingis_fact_eventis now framework-internal — it can no longer be passed as an option- Email subsystem deprecation — the email subsystem is deprecated in favor of application-level notification
identity_type = "uuid"yields a string — uuid identities are now UUID strings, not nativeuuid.UUIDobjects- Data that used to fit may now be rejected: length bounds are checked after sanitization
- Streams are trimmed by default: profiles set
retention_maxlen, and a single-consumer stream can lose unread entries - A failing handler now pauses itself: the circuit breaker is on by default
/readyzreports an object where it reported a number: response-shape breakupdate()now goes through the same path assave(): timestamps, enrichers and OCC- Errors you may start seeing: fixes that turned silent misbehaviour into exceptions
protean checkreports more: CI can fail on unchanged code- A failing handler method no longer stops its siblings: every
@handlemethod for an event now runs (0.17.1) - Sibling handler classes are independent in failure under synchronous dispatch: every handler class for an event now runs under
event_processing = "sync"(0.17.1) - Smaller changes worth knowing
The Unit of Work is a real transaction
Protean 0.17 maps a Unit of Work onto one real database transaction on the
PostgreSQL and MSSQL adapters, replacing a model where the engine ran in
AUTOCOMMIT and the UoW buffered writes in memory until commit. See
ADR-0027
for the measurements behind the decision.
This section is longer than the others on purpose. The change is a correctness fix, and most codebases get quietly better. But two shapes that were harmless before are not any more, and neither of them appears in a diff of your code, because your code does not change. You have to go looking.
What you are gaining
Worth knowing first, because it reframes the work below as paying down a debt rather than absorbing a regression. Under the old model, on PostgreSQL and MSSQL:
- A rollback could leave a corrupted aggregate. Saving an aggregate with
child entities forced a mid-UoW flush. Under
AUTOCOMMITthat flush committed the parent row durably while the children were still pending, so a later rollback left an orphaned parent with none of its children. - The transactional outbox was not atomic. A UoW writing to two tables where the second write failed left the first durably committed. A domain write and its outbox message could split, which is the exact failure the outbox pattern exists to prevent.
- Reads inside a UoW did not see the UoW's own writes. A
filter,countorexistsnever flushed pending changes, so it returned stale results and in-UoW uniqueness checks read committed state.
All three are fixed by the same change. The Memory and SQLite adapters already behaved this way, so this brings PostgreSQL and MSSQL into line with them.
Change 1: a nested Unit of Work joins the outer transaction
A UnitOfWork opened while another is already active on the same context no
longer runs independently. It joins the outermost transaction, and there are
no savepoints, so a rollback anywhere inside dooms the whole transaction.
with UnitOfWork():
repository_for(Order).add(order)
try:
with UnitOfWork(): # joins the outer transaction
repository_for(Audit).add(entry)
raise RuntimeError("audit failed")
except RuntimeError:
pass # the exception is handled...
# ...but on 0.17 the outer transaction is already doomed, and the
# order is not saved either. On 0.16 the order was saved.
The shape to look for is a nested block whose failure you deliberately swallow so the outer work survives. That no longer works.
Nesting is otherwise safe, and in one way safer than before: previously a nested UoW shared the outer session, so an inner commit durably persisted the outer's pending writes. Only the outermost UoW commits now.
This is most likely to bite you indirectly, where a handler opens a UoW and calls a service method that opens its own. Neither function looks nested on its own.
Change 2: external I/O inside a Unit of Work now holds locks
A real transaction holds its row locks and its pooled connection for as long as
the block runs. Under AUTOCOMMIT no transaction was open between flushes, so an
HTTP call inside a UoW cost only wall-clock time. Now it holds database locks for
the duration of that call, and a pooled connection with it.
# Costly on 0.17: locks and a connection are held for the whole HTTP call.
with UnitOfWork():
order = repository_for(Order).get(order_id)
order.confirm()
repository_for(Order).add(order)
httpx.post("https://partner.example/webhook", json=payload) # <-- move this
# Better: commit first, then talk to the outside world.
with UnitOfWork():
order = repository_for(Order).get(order_id)
order.confirm()
repository_for(Order).add(order)
httpx.post("https://partner.example/webhook", json=payload)
Best of all, raise a domain event and let the outbox publish it after the transaction commits, which also gets you delivery retries.
Both fixes above move the call after a with UnitOfWork(): block. Inside a
@handle method there is no "after the commit": @handle owns the Unit of Work
and the method returns before it commits, so you cannot append the call at the
end. Split the method instead. A method that only calls out reaches no repository
and runs no transaction, and when the call must follow the write, the persisting
method raises an event that a second handler acts on. See Calling External
Systems from Handlers.
The shapes to look for are an HTTP client call, a broker publish, an email send, and anything else that waits on a system you do not control.
How to find both in your codebase
Do not do this by hand. protean upgrade-check reports both:
protean upgrade-check --domain=my_domain
It reports NESTED_UNIT_OF_WORK for blocks opened inside another,
IO_INSIDE_UNIT_OF_WORK for external calls inside one, and, when it finds no
lexical nesting, a UNIT_OF_WORK_NESTING_REVIEW note listing how many blocks
are worth walking for the indirect case. The rules read your source and change
nothing.
They are advisory and deliberately incomplete in one direction: nesting through a call cannot be seen statically, so a clean report is not proof. Walk the call graph under your UoW blocks for anything that opens its own.
Two things to size before you upgrade
- Connection pool. Each concurrent UoW holds a pooled connection for its
whole transaction. Size
pool_sizeto peak UoW concurrency under a threaded host. The async engine runs on one event-loop thread and is not exposed to this. - MSSQL locking. Read-committed on MSSQL uses row locks rather than MVCC, so
a reader can block on a writer holding a row inside a UoW where PostgreSQL
would not. Enable
READ_COMMITTED_SNAPSHOTif that blocking shows up.
Finally, prefer with UnitOfWork(): over a manual uow.start(). Without a
try/finally, a manual start leaks the transaction, its connection and its locks
if an exception is raised before commit or rollback.
event_sourced replaces is_event_sourced
Tier 1. Boolean element options are now bare predicates. The aggregate
option that enables event sourcing is event_sourced; the old
is_event_sourced spelling is a deprecated alias.
@domain.aggregate(event_sourced=True) # was: is_event_sourced=True
class Account:
...
The alias still works but emits a RemovedInProtean10Warning and is reported as
a DEPRECATED_OPTION diagnostic by protean check. It will be removed in
v1.0.0. If both spellings are supplied, event_sourced wins. The internal
meta_.is_event_sourced attribute is unchanged.
is_fact_event is now framework-internal
Internal option removed (no deprecation window). is_fact_event was never a
public option user code should set: the framework assigns it when it generates
fact events for an aggregate marked fact_events=True. Because it was never part
of the public API, it is removed outright rather than deprecated: passing
is_fact_event= to an event or command (via the decorator or domain.register)
now raises ConfigurationError immediately.
Nothing changes for reads: meta_.is_fact_event is still present on every
event and command (defaulting to False), and fact events still carry
meta_.is_fact_event is True. If you were setting the option by hand, drop it
and let fact_events=True on the aggregate drive generation.
Email subsystem deprecation
Tier 1. The built-in email subsystem is deprecated and will be removed in
v1.0.0. Sending email is an infrastructure concern that belongs in an
application-level notification service, not in the domain framework. Every email
entry point still works but now emits a RemovedInProtean10Warning:
- registering an email element, via either
@domain.emailordomain.register(SomeEmail); domain.send_email(...)anddomain.get_email_provider(...);- configuring a non-default
[email_providers]block indomain.toml.
Deprecation warnings are silent by default. Surface them by running Python with
-W default::DeprecationWarning, and run protean check to list every
registered email element as a DEPRECATED_EMAIL info diagnostic, and a
non-default [email_providers] block as a DEPRECATED_CONFIG info diagnostic
(neither fails the check).
What to do
Move notification out of the domain. Handle the domain event (or subscribe to the stream) and call your own notification service, which owns the email transport:
@domain.event_handler(part_of=Person)
class SendWelcomeNotification:
@handle(PersonAdded)
def on_person_added(self, event: PersonAdded) -> None:
# `notifications` is your application-level service wrapping whatever
# email/SMS/push transport you use (SendGrid, SES, a queue, ...).
notifications.send_welcome(to=event.email, name=event.first_name)
This keeps the domain focused on what happened (PersonAdded) and leaves the
delivery mechanism to infrastructure you control, so it can evolve independently
of the framework.
identity_type = "uuid" yields a string
Behavior change. When identity_type = "uuid", an auto-generated identity is
now a UUID string in Python, not a native uuid.UUID object (ADR-0021).
Previously the auto-injected id field held a native UUID, which is not
JSON-serializable and broke to_dict(), event and command payloads, and API
responses; an explicitly declared Auto(identity_type="uuid") field already
produced a string, so the two disagreed. They now agree on str.
Nothing changes for storage: adapters that support a native UUID column still use
one (the SQLAlchemy adapter maps the uuid type to its GUID column). Integer
identities are unaffected.
If you have code that treated a uuid id as a native uuid.UUID (calling .hex or
.int, or passing it where a UUID is required), wrap it explicitly:
uuid.UUID(entity.id). Comparisons and lookups by id keep working, since a
repository get(id) accepts the string form.
Data that used to fit may now be rejected
Length bounds on String and Text are checked against the sanitized
value, not the raw one. sanitize=True and max_length=255 are the defaults on
every String, and sanitizing escapes &, <, >, " and ' into HTML
entities, each of which is longer than the character it replaces.
So a value that fit in 0.16 can be rejected in 0.17 without your code or your data changing:
class Note:
body = String(max_length=10)
Note(body="a & b & c") # 9 characters
# ValidationError: String has 17 characters after sanitization,
# exceeding max_length of 10
& becomes &, so nine characters became seventeen.
Who this hits. Any field holding text a human typed: names with &,
addresses, free-text notes, anything pasted from a rich-text editor. The
narrower the max_length, the more likely it is.
What to do. Either raise max_length to leave room for escaping, or set
sanitize=False on fields whose content is never rendered as HTML:
body = String(max_length=64, sanitize=False)
Reserve roughly 5x for text likely to be entity-heavy. See ADR-0026 for why the check moved after sanitization rather than before.
Streams are trimmed by default, and trimming can drop unread messages
Every named subscription profile now sets retention_maxlen:
| Profile | retention_maxlen |
|---|---|
production |
100,000 |
fast |
100,000 |
batch |
500,000 |
debug |
1,000 |
projection |
none |
| no profile | none |
If you name a profile, your streams are now capped. In 0.16 they grew until something else trimmed them.
The part to read carefully. What trimming does depends on how many consumer groups read the stream:
- Two or more groups: the stream is trimmed to the slowest group's read
position and
retention_maxlenis ignored. Nothing unread is lost. - One group, or none: the stream is capped at
retention_maxlen, and this is not progress-safe. If the single reader falls further behind than the cap, the oldest unread entries are deleted. That happens during an initial catch-up over a pre-existing stream, after an outage, or whenever the producer outruns the handler.
debug's cap of 1,000 is the sharp edge: it is meant for watching one message
at a time in development, and it will discard a backlog.
What to do. If you name a profile on a single-consumer stream, size
retention_maxlen above the largest backlog you expect rather than the
steady-state length, or set it to none and trim out of band. See
Tuning subscriptions.
A failing handler now pauses itself
Stream subscriptions have a circuit breaker on by default: 10 consecutive handler failures pause reads for 60 seconds, then a single probe message decides whether to resume.
Nothing is lost. Messages stay in the stream and the pending list and are redelivered. But a subscription that used to grind through a persistent failure, sending everything to the DLQ, now stops instead, and that looks like a stall if you are not expecting it.
An open breaker does not make the engine unready: /readyz still returns
200, because the pod is healthy and its other subscriptions are still working.
Tune or disable it per handler:
[server.subscriptions.PaymentHandler]
circuit_breaker_threshold = 50 # more tolerant
circuit_breaker_reset_seconds = 10
/readyz reports an object where it reported a number
The engine's readiness probe changed shape:
- "subscriptions": 12
+ "subscriptions": {"total": 12, "details": [...]}
Anything parsing checks.subscriptions as a number breaks: a dashboard panel, an
alert expression, a script doing checks.subscriptions > 0.
The block now carries per-subscription lag, pending count, DLQ depth, status and
circuit-breaker state. It is informational and never changes the probe's verdict.
The FastAPI health router is unchanged and still has no subscriptions key at
all, so a script reading both entry points must treat it as optional. Full shape
in Server hardening.
update() now goes through the same path as save()
repository.update() and QuerySet.update() used to write fields directly. They
now run the full persistence path, which means three changes at once:
auto_nowtimestamps are stamped, soupdated_atmoves on an update that previously left it alone.- Pre-persist enrichers run.
- The optimistic-concurrency version advances, so a concurrent update that
silently won before now raises
ExpectedVersionError.
And update() on an entity that was never persisted now raises
ObjectNotFoundError instead of quietly doing nothing.
This is the correct behaviour and matches save(), but it will surface in tests
that asserted a frozen updated_at, and in code that relied on last-write-wins.
Config that was silently ignored now stops start-up
Behavioural break. Three config guards used to test a value's truthiness before its type, so a falsy wrong value was read as "not set" and skipped in silence while a truthy one raised. Turning a feature off is the likelier thing to write than turning it on, so the shape most people would use was the shape nobody heard about.
They now check the type first. A value of the wrong shape is rejected whether it is falsy or not, which means config that started fine on 0.16 can stop start-up on 0.17.
Who is affected
Anyone whose domain.toml has one of these:
[server]
priority_lanes = false # a scalar, not a table
profiles = false # a scalar, not a table
[caches.default]
TTL = 0 # or a negative, or "nan", or "inf"
None of these did what they look like they do. priority_lanes = false did not
disable priority lanes (they were already off by default), it was simply
discarded. TTL = 0 reached the memory cache as an immediate expiry and the
Redis cache as an invalid one.
What to do
Write the tables as tables, and give the cache a real TTL:
[server.priority_lanes]
enabled = false # or delete the section entirely
[caches.default]
TTL = 3600 # a positive, finite number of seconds
The error names the key and shows the correct form, so a domain that fails to start tells you which line to change.
Also newly rejected
cache.add(projection, ttl=0) used to fall through to the cache default, so a
caller asking for immediate expiry silently got 300 seconds. It now raises. Pass
a positive number of seconds, or omit the argument to use the cache's own TTL.
Errors you may start seeing
Several fixes in 0.17 converted silently wrong behaviour into a raised exception. Each is right on its own; together they mean an upgrade can turn a quiet bug into a loud failure in code you did not touch. If one of these appears after upgrading, it is usually reporting a real problem that was there before.
| Exception | Now raised when | Why |
|---|---|---|
ExpectedVersionError |
A concurrent update loses the race, including from a filter/count read inside a Unit of Work |
Optimistic concurrency actually conflicts now: memory compare-and-set, SQLAlchemy version_id_col, and a child-entity edit bumping the root version |
ObjectNotFoundError |
update() on an entity that was never persisted |
It used to do nothing |
ValidationError |
Text exceeds max_length after sanitization; a duplicate violates Index(unique=True) on the in-memory repo |
Bounds are checked post-sanitization; the memory adapter now enforces unique indexes |
ConfigurationError |
A [databases.*] section names an unknown provider; __version__ = True on an element; is_fact_event passed as an option |
Misconfiguration that used to be ignored is now refused at startup |
The read-inside-a-UoW case deserves a note: with a real transaction, a read
flushes pending writes first, so a version-guarded UPDATE can lose its race at
the read rather than at the commit. It is translated to ExpectedVersionError
so your existing version-retry path catches it.
protean check reports more, so CI may go red
The rule catalogue roughly doubled. New warning-level rules include
CIRCULAR_CLUSTER_DEPENDENCY, COMMAND_HANDLER_CROSS_CLUSTER,
CROSS_AGGREGATE_REFERENCE, ES_AGGREGATE_NO_EVENTS,
EVENT_HANDLER_FOREIGN_EVENT, INFRA_IMPORT_IN_DOMAIN,
PROJECTOR_HANDLES_ORPHANED_EVENT, QUERY_HANDLER_WITHOUT_QUERY,
UNBOUNDED_INDEXED_STRING, UNINDEXED_FILTER_PATH, UPCASTER_GAP,
VALUE_OBJECT_MUTABLE_FIELD and DEPRECATED_OPTION.
protean check exits 2 on warnings, unchanged from 0.16, so a domain that
passed can now fail CI without any change to your code.
What to do. Fix them, they are real findings. To adopt gradually, either raise the gate so only errors fail:
[lint]
level = "error"
or suppress specific findings while you work through them, with
[lint].suppressions. See
Architecture fitness functions.
cache.get_ttl() returns seconds on Redis, not milliseconds
Behavioural break, Redis cache only. RedisCache.get_ttl() returned Redis'
PTTL unchanged, which is milliseconds, while MemoryCache.get_ttl() returned
seconds. The same method meant different things depending on the adapter, and
every other TTL on the port is seconds: the TTL config key, add(ttl=...),
set_ttl(). Redis now returns seconds too.
Who is affected
Anyone calling get_ttl() on a Redis-backed cache. The value is now 1000x
smaller. Code that divided by 1000 to get seconds is now dividing seconds by
1000:
# Before: correct on Redis, wrong on the memory cache
seconds_left = cache.get_ttl(key) / 1000
# After: correct on both
seconds_left = cache.get_ttl(key)
Redis' two sentinel answers are unchanged: -1 (the key exists with no expiry)
and -2 (no such key) are flags rather than durations, so they are returned as
they are rather than scaled.
Nothing inside Protean calls get_ttl(), so this only affects your own code.
Why it was not deprecated first
There is no shape that could carry both meanings through a deprecation window: the return is a bare number, so an old caller and a new caller want different values from the same call. Correcting it outright, in a release that already documents the unit, was judged clearer than shipping two spellings.
Application services accept constructor arguments
Fixed, not a break. @domain.application_service appeared to forbid
constructor injection: any service defining __init__ failed with
TypeError: object.__new__() takes exactly one argument (the type to instantiate)
That was a bug, not a rule. __new__ forwarded its arguments to
object.__new__, which takes only the class. Services can now take constructor
arguments, which is what makes them testable with fakes:
@domain.application_service(part_of=Order)
class PlaceOrder:
def __init__(self, gateway):
self.gateway = gateway
@use_case
def run(self, order_id): ...
# in a unit test
service = PlaceOrder(gateway=FakeGateway())
Nothing that worked before stops working; this only accepts what used to raise.
A @use_case method still needs an active domain context, because it opens a
Unit of Work. That is by design, but the error said
AttributeError: 'NoneType' object has no attribute 'providers' from inside the
transaction machinery. It now names the use case and tells you to wrap the call
in with domain.domain_context(): or use the test_domain fixture.
A failing handler method no longer stops its siblings
Behavioural break, event handlers and projectors (0.17.1). An event handler or
projector can register several @handle methods for the same event. Each one
already ran in its own Unit of Work, so they were transactionally independent.
They were not independent in failure: the first exception propagated and the
remaining methods never ran. Because the registry is a set, which methods got
skipped was unspecified: it can shift with any change to the code.
Every method now gets its turn, apart from two exceptions named below, and the failures are collected and raised once dispatch finishes.
Who is affected
Anyone with more than one @handle method for a single event, where one of them
can fail. Methods that used to be skipped now run, so their side effects now
happen in a failure that previously suppressed them.
@domain.event_handler(part_of=Order)
class OrderReactions:
@handle(OrderPlaced)
def reserve_stock(self, event): ... # may raise
@handle(OrderPlaced)
def notify_partner(self, event): ... # before: might never run
# after: no longer skipped
If two methods were coupled so that the second must not run when the first fails, they were relying on an ordering the framework never guaranteed. Put them in one method, where ordering is ordinary code, or chain them: have the first raise an event that the second handles.
What surfaces to the caller
One failure propagates unchanged, so a handle_error override that matches on
exception type keeps working. Two or more are raised together as an
ExceptionGroup, so no failure is lost to whichever one happened to be last.
Anything matching on exception type has to unwrap exc.exceptions; see
Classify async processing errors.
Under event_processing = "sync" the dispatch runs inside the Unit of Work's
commit, which classifies by exception type. A group is not one of the types it
knows, so application code at repository.add() or at the end of a
with UnitOfWork(): block sees a TransactionError with the group as its
__cause__. Neither except SomeError: nor except* SomeError: matches; walk
__cause__.exceptions instead.
That reclassification has a sharp edge worth knowing. One failing sibling raising
ValueError still reaches the caller as ExpectedVersionError, because the
commit translates a bare ValueError. Two failing siblings become a
TransactionError. Adding a second failing method changes the exception class
your application code catches.
Two failures are not collected
An ExpectedVersionError propagates immediately and does stop the siblings
that have not run yet. Grouping it would hide it from the Unit of Work's commit
classification, which would surface the conflict as a TransactionError and stop
the enclosing handler's version retry from firing. The conflicting method's own
retry has already run and exhausted by this point.
A BaseException that is not an Exception, such as KeyboardInterrupt,
also stops dispatch where it is raised.
On both paths, failures gathered before the one that ended dispatch are attached to it as a note and logged at ERROR, since nothing downstream would otherwise report them.
What this does not cover
This change is per handler method. Two handler classes subscribing to one event were isolated from each other under async processing, where each has its own subscription, and not yet under synchronous dispatch. That gap is closed separately (see Sibling handler classes are independent in failure under synchronous dispatch). Process managers run their own dispatch loop and are unchanged.
Redelivery
Delivery is at-least-once, so a failed message is retried (broker redelivery, or
the event-store recovery pass while enable_recovery is on), and the sibling
methods that already committed run again.
For a projector, idempotent=True handles that: the marker is keyed per
handler method, so each sibling deduplicates on its own and an already-applied
method is skipped on the retry. It needs an event carrying a message id and a
projection on a provider with a marker store, so a cache-backed projection gets
no deduplication (see ADR-0017).
Event handlers have no such option. idempotent is projector-only, so a
sibling that already committed runs again on the retry, and its method has to
tolerate that.
Why it was not put behind a flag
The skipping was a defect against the model the framework documents, and which siblings got skipped was arbitrary, so no program could depend on it deliberately. A flag would have preserved a behaviour that cannot be reasoned about. See ADR-0031.
Sibling handler classes are independent in failure under synchronous dispatch
Behavioural break, event_processing = "sync" (0.17.1). Two event handler or projector
classes can subscribe to the same event. Under async each class has its own
subscription, so one class failing never touched the others. Under synchronous
dispatch the drain stopped at the first class that raised, and every class queued
behind it was discarded. Now every class runs, and the failures surface once the
drain finishes. This completes the guarantee
ADR-0031
states, which the handler-method
change delivered only for
methods within one class.
Who is affected
Anyone with more than one handler class subscribing to one event under
event_processing = "sync", where one class can fail. Classes that used to be
skipped now run, so their side effects now happen in a failure that previously
suppressed them.
@domain.event_handler(part_of=Order)
class ReserveStock:
@handle(OrderPlaced)
def reserve(self, event): ... # may raise
@domain.event_handler(part_of=Order)
class NotifyPartner:
@handle(OrderPlaced)
def notify(self, event): ... # before: might never run under sync
# after: no longer skipped
What surfaces to the caller
The drain runs inside the enclosing Unit of Work's commit, which classifies what
escapes it by exception type. So the shape a caller sees at repository.add() (or
the end of a with UnitOfWork(): block) is not the handler's exception verbatim,
and handle_error — an async-engine hook — does not fire on this path at all.
One failing class hits the same sharp edge the handler-method change describes:
the commit reclassifies it. A bare ValueError surfaces as ExpectedVersionError,
and most other exceptions surface as a TransactionError with the failure as its
__cause__ (a few the commit handles specially, such as ConfigurationError,
pass through as themselves). Adding a second failing class changes the class your
except catches.
Two or more failing classes are raised together as an ExceptionGroup, which the
commit wraps as a TransactionError with the group as its __cause__. As in the
handler-method case, neither except nor except* on that TransactionError
matches the failures inside — walk __cause__.exceptions. One wrinkle specific to
classes: a handler class that itself has several failing @handle methods
contributes a nested sub-group, so recurse into any member that is itself an
ExceptionGroup (or re-raise __cause__ and match it with except*, which does
recurse) rather than stopping at the first level.
An ExpectedVersionError and a BaseException that is not an Exception are the
two exclusions. Each ends the drain where it is raised and propagates at once, for
the same reasons they do among sibling methods: a version conflict has to reach
the commit classification as itself so version retry still fires, and an interrupt
must not be swallowed. Failures gathered before either one are attached to it as a
note and logged at ERROR.
What this does not cover
The isolation is a property of the synchronous drain, so it reaches more than two
sibling classes for one event: reactions to different events raised in the same
commit, and a process manager taking part as one handler class among others, are
collected the same way. What is not touched: a process manager's own dispatch
loop still stops at the first of its @handle methods to fail (what that means
for the instance's state is a separate decision), and broker subscribers under
message_processing = "sync" still stop at the first failing subscriber. Both are
tracked separately.
Smaller changes worth knowing
- The health probe binds
127.0.0.1, not0.0.0.0. Kubernetes probes from outside the pod will fail until you set[server.health] host = "0.0.0.0".protean upgrade-checkreports this asHEALTH_PORT_BIND. - Synchronous dispatch is breadth-first. A nested
process(cmd, asynchronous=False)returns before its downstream cascade runs, so a read immediately after it may not see the effects yet. See ADR-0016. version=is honoured on explicitBaseEvent/BaseCommandsubclasses. The type string was pinned atv1regardless; it now reflects the declared version, which changes__type__on both write and read matching. Stored...v1messages will not match a class that now reportsv3, so add an upcaster or keep the version.__version__ = Truenow raises.- datetime and date payloads use ISO-8601 (
isoformat()) in the event store, Elasticsearch and the outbox. Existing records stay readable; external consumers comparing raw timestamp text need updating. Outbox.target_brokeris NOT NULL. Existing tables are not altered automatically. Backfill and alter before upgrading;protean upgrade-checkreports outbox schema drift.Auto(increment=True)inserts flush early, so a constraint violation surfaces atadd()rather than atcommit(). Error handling wrapped only aroundcommit()will stop catching it.- A
ValueObjectis now considered present by identity, not truthiness. An all-default value object round-trips instead of reading back asNone. $allsubscriptions wait at position gaps, up to[server.event_store_subscription] gap_timeout_seconds(default 5), which adds latency but stops events being skipped. See ADR-0025.protean.utils.__all__is trimmed.from protean.utils import *no longer brings inderive_element_class,generate_identity,fully_qualified_name,convert_str_values_to_list,TypeMatcherorutcnow_func. Direct imports still work, behind a deprecation warning.- CLI removals.
protean generateandgenerate docker-composeare gone, as is--debugonserverandobservatory(deprecated in 0.16; use--log-level DEBUG). - Dependency floors raised:
elasticsearch>=8.18withelasticsearch-dsldropped, plus newer floors forfastapi,typer,uvicornand OpenTelemetry.import elasticsearch_dslwill fail. - Committed IR baselines go stale. The IR gained fields, so
protean-check-stalenessreports STALE on the first run after upgrading. Regenerate withprotean ir show --canonical. - New deprecations (warnings only, nothing removed yet):
publishedon commands, theMethodandNestedfields, andList(pickled=True).