Migrating to 0.19
From 0.18 to 0.19
This page lists the changes in 0.19 that may need action when you upgrade.
On this page:
- Outbox claim order: Rows of equal priority are claimed in the order they were written, and the
ix_outbox_activeindex gains two columns - Outbox rows wait for their partition key's earlier rows: A new
stream_categorycolumn andix_outbox_hold_backindex, and a partition key can now halt - SQL Server stores
DateTimeasDATETIME2: New tables keep microseconds; convert existingDATETIMEcolumns, the outbox'screated_atfirst eventstore verifyexits 2 when it cannot connect: A connection error is now a usage error, with an envelope under--json- Generated
.protofiles import each other: Compile them with-I <output>/schemas, and a value object from another module keeps its own full name - Deprecation versions must be strings:
deprecated=andir diffrefuse asinceorremovalversion written as a number, andir diffrefuses a malformeddeprecatedblock - The IR's
$schemaURL moved: New IR files name the schema atdocs.proteanhq.com, and older baselines stay fresh - New in
ir diff: value type changes onDictandListfields: Adding, changing or removing a value type is a breakingfield_type_changed - Email port removed:
@domain.email,Domain.send_email(), the email providers and thesendgridextra are gone - State-based aggregates save their events only through
add(): Events raised on an aggregate that was only read are not saved at commit
Outbox claim order
The outbox processor used to claim rows by priority alone. Rows of the same priority came back in whatever order the database returned them, so on PostgreSQL one aggregate's events could be published out of commit order.
claim_batch and find_unprocessed now order rows by:
priority, highest first.created_at, oldest first.sequence_number, lowest first.id, lowest first.
The last key, id, says nothing about commit order. It only makes the order
the same on every run when the other three keys tie.
The Unit of Work now writes sequence_number on every outbox row. It holds
the event's position within its commit: 0 for the first event, 1 for the
next, and so on. Each database provider has its own outbox table, and the
count starts at 0 for each provider in the commit. Within a provider, the Unit
of Work numbers events one aggregate at a time, each aggregate's events in the
order it raised them. When two rows share a
created_at, the row with the lower number is claimed first. Every outbox row
written for one event (the internal row and one row per external broker)
carries the same number. The startup sweep that rebuilds lost outbox rows from
the event store numbers the rows it rebuilds in event store order.
When can the claim order differ from commit order?
created_at is the time the Unit of Work built the row, just before the
commit. One aggregate's events go out in commit order, because a second commit
on that aggregate has to load it after the first commit finished. Rows of
different aggregates go out in the order their rows were built, which can
differ from the order their commits finished.
The claim order can also differ from commit order in these cases:
- A higher-priority message is claimed ahead of an earlier lower-priority one.
- A retried row, or a second worker, publishes events out of order. This
does not apply to a category partitioned with
sequential_by: a later row of a key waits until the key's earlier rows have published (see below). created_atcomes from the clock of the host that wrote the row. If two hosts' clocks disagree, or a clock steps back, a later commit can get an earliercreated_atand be claimed first.- On a SQL Server outbox table created before 0.19 and not converted,
created_atis aDATETIME, which rounds to about 3 milliseconds. Rows of two commits that land in the same tick share acreated_at, and since each commit numbers its events from 0, their rows can interleave. See SQL Server storesDateTimeasDATETIME2.
Rows written before the upgrade
Rows already in the outbox have a NULL sequence_number. They are ordered by
created_at first, like every other row. Among rows with the same
created_at, a NULL sequence_number sorts after every number, on the
in-memory adapter and every SQL database.
To place NULLs the same way everywhere, ordering on a nullable column now puts NULLs last when ascending and first when descending on SQLite, MySQL, MariaDB and SQL Server. PostgreSQL and the in-memory adapter already did this. The rule applies to every repository query that orders by a nullable field, not only to the outbox. If a query relied on NULLs coming first in an ascending sort on one of those databases, it now gets them last.
Rebuild ix_outbox_active
The partial polling index ix_outbox_active now covers the claim order:
status, priority DESC, created_at, sequence_number.
- New deployments: Nothing to do. Tables Protean creates on 0.19 get the new index. A schema you manage yourself, with Alembic or by hand, needs the rebuild below.
- Existing deployments: Protean does not change an existing table's indexes. Claims work without the rebuild, but the old index does not cover the new sort. Drop and recreate the index once.
The statements below use the default table name outbox.
=== "PostgreSQL"
```sql
DROP INDEX IF EXISTS ix_outbox_active;
CREATE INDEX ix_outbox_active
ON outbox (status, priority DESC, created_at, sequence_number)
WHERE status IN ('pending', 'failed');
```
On a busy table, use `DROP INDEX CONCURRENTLY` and
`CREATE INDEX CONCURRENTLY` to avoid blocking writes. Neither runs inside
a transaction block.
=== "SQLite"
```sql
DROP INDEX IF EXISTS ix_outbox_active;
CREATE INDEX ix_outbox_active
ON outbox (status, priority DESC, created_at, sequence_number)
WHERE status IN ('pending', 'failed');
```
=== "SQL Server (full index)"
Protean builds a full index on SQL Server, without the `WHERE` filter.
```sql
DROP INDEX IF EXISTS ix_outbox_active ON outbox;
CREATE INDEX ix_outbox_active
ON outbox (status, priority DESC, created_at, sequence_number);
```
=== "MySQL / MariaDB (full index)"
MySQL and MariaDB have no partial indexes, so Protean builds a full
index there.
```sql
DROP INDEX ix_outbox_active ON outbox;
CREATE INDEX ix_outbox_active
ON outbox (status, priority DESC, created_at, sequence_number);
```
Outbox rows wait for their partition key's earlier rows
A handler category that declares
sequential_by gives each outbox row a
partition key. The outbox processor used to claim and publish each row on its
own. If a key's first row failed, its second row could still publish, and the
consumer saw the two events out of commit order.
The claim now groups partitioned rows by target broker, stream category,
partition key and priority lane. A row is claimed only when every earlier row
of its group has been published, or is claimed in the same call. An earlier
row holds the later ones while it is pending, processing, failed or
abandoned. Rows without a partition key are claimed as before.
Add the stream_category column
The outbox table has a new stream_category column. The Unit of Work copies
it from the event's metadata.domain.stream_category when it writes a row. A
new index, ix_outbox_hold_back, backs the lookup of a row's earlier group
rows.
- New deployments: Nothing to do. Tables Protean creates on 0.19 have the column and the index.
- Existing deployments: Protean does not change an existing table. Add the column before you start 0.19, because every outbox query reads it. Then create the index.
The statements below use the default table name outbox.
=== "PostgreSQL"
```sql
ALTER TABLE outbox ADD COLUMN stream_category VARCHAR(255);
CREATE INDEX ix_outbox_hold_back
ON outbox (target_broker, stream_category, partition_key, status);
```
On a busy table, use `CREATE INDEX CONCURRENTLY` to avoid blocking
writes. It does not run inside a transaction block.
=== "SQLite"
```sql
ALTER TABLE outbox ADD COLUMN stream_category VARCHAR(255);
CREATE INDEX ix_outbox_hold_back
ON outbox (target_broker, stream_category, partition_key, status);
```
=== "SQL Server"
```sql
ALTER TABLE outbox ADD stream_category VARCHAR(255);
CREATE INDEX ix_outbox_hold_back
ON outbox (target_broker, stream_category, partition_key, status);
```
=== "MySQL / MariaDB"
```sql
ALTER TABLE outbox ADD COLUMN stream_category VARCHAR(255);
CREATE INDEX ix_outbox_hold_back
ON outbox (target_broker, stream_category, partition_key, status);
```
Rows already in the outbox
Rows already in the outbox have a NULL stream_category. The claim treats
them as rows without a partition key: they are not held, and they do not hold
later rows.
To keep a key in order across the upgrade, fill the column on the rows that
are not yet published. Protean stores each row's metadata pickled, so plain
SQL cannot read the stream category out of it. Run this function once, with
0.19 installed, after the ALTER TABLE and before you start the server:
from protean.utils.outbox import OutboxStatus
UNPUBLISHED = [
OutboxStatus.PENDING.value,
OutboxStatus.PROCESSING.value,
OutboxStatus.FAILED.value,
OutboxStatus.ABANDONED.value,
]
def fill_stream_category(provider: str = "default", page_size: int = 500) -> int:
filled = 0
skipped = 0
with domain.domain_context():
repo = domain._get_outbox_repo(provider)
while True:
rows = (
repo.query.filter(
partition_key__isnull=False,
stream_category__isnull=True,
status__in=UNPUBLISHED,
)
.order_by(["created_at", "id"])
.offset(skipped)
.limit(page_size)
.all()
.items
)
if not rows:
return filled
for row in rows:
meta = row.metadata_.domain
if meta is None or meta.stream_category is None:
skipped += 1
continue
row.stream_category = meta.stream_category
repo.add(row)
filled += 1
It returns the number of rows it filled. Pass the provider name when the
outbox lives on a provider other than default. A row whose metadata has no
stream category is left NULL.
The fill also sets the column on abandoned rows, and a filled abandoned row
halts its key as soon as 0.19 starts (see the next section). That includes
every row the key gets after the upgrade. Before you run the fill, list the
abandoned rows that have a partition key, and retry or skip the ones you do not
want to halt their key:
SELECT id, partition_key, created_at, last_error
FROM outbox
WHERE status = 'abandoned' AND partition_key IS NOT NULL;
A partition key can halt
A key whose earliest row is abandoned stops: none of its later rows publish
until someone deals with that row. A key whose earliest row is failed waits
until the row's retry is due. When the row runs out of retries it becomes
abandoned and the key stops. Other keys, rows without a key, and the other
priority lane keep publishing.
The cleanup job no longer deletes abandoned rows that have a partition key, since deleting one would let the key's later rows publish out of order.
To restart a halted key, either retry the abandoned row or skip it. The outbox guide shows how to find abandoned rows. Retrying sends the row again with a fresh retry budget:
UPDATE outbox
SET status = 'pending', retry_count = 0, locked_by = NULL, locked_until = NULL
WHERE id = '<row id>';
Skipping the row marks it published without sending it. Its event is dropped for that broker, and the key's next row publishes on the next tick:
UPDATE outbox
SET status = 'published', published_at = CURRENT_TIMESTAMP,
locked_by = NULL, locked_until = NULL
WHERE id = '<row id>';
Do not delete the row. Outbox reconciliation runs when the server starts and
can rebuild a deleted row of a recent event as pending. The rebuilt row would
then publish after the key's later rows.
SQL Server stores DateTime as DATETIME2
On SQL Server, a DateTime field used to map to a DATETIME column, which
rounds to increments of about 3 milliseconds. Protean writes
microsecond-precision timestamps, so two values written less than 3 ms apart
could read back equal, and a value read back could differ from the one written.
A DateTime field now maps to DATETIME2, which keeps 100-nanosecond steps.
Date fields still map to DATE. Other databases are unchanged.
- New deployments: Nothing to do. Tables Protean creates on 0.19 get
DATETIME2columns. - Existing deployments: Protean does not change an existing table, so its
columns stay
DATETIMEand keep rounding. Convert them.
The column that matters most is the outbox's created_at. The outbox claims
rows in created_at order, so rounding it can publish two commits' events
interleaved (see
When can the claim order differ from commit order?).
Convert a column
ALTER TABLE <table> ALTER COLUMN <column> DATETIME2 NULL;
Write NULL for an optional field and NOT NULL for a required one.
ALTER COLUMN sets the column's nullability to what the statement says, and
makes it nullable when the statement says nothing.
SQL Server refuses to alter a column that an index or a constraint uses. Drop
an index with DROP INDEX <index> ON <table> and create it again after the
ALTER. A DateTime(unique=True) field has a UNIQUE constraint with a name
SQL Server generated. Find the name in sys.key_constraints, drop it with
ALTER TABLE <table> DROP CONSTRAINT <name>, and add it back with
ALTER TABLE <table> ADD UNIQUE (<column>) after the ALTER.
Convert the outbox
All five outbox timestamps are nullable, and ix_outbox_active uses
created_at. The statements below use the default table name outbox:
DROP INDEX IF EXISTS ix_outbox_active ON outbox;
ALTER TABLE outbox ALTER COLUMN created_at DATETIME2 NULL;
ALTER TABLE outbox ALTER COLUMN published_at DATETIME2 NULL;
ALTER TABLE outbox ALTER COLUMN last_processed_at DATETIME2 NULL;
ALTER TABLE outbox ALTER COLUMN next_retry_at DATETIME2 NULL;
ALTER TABLE outbox ALTER COLUMN locked_until DATETIME2 NULL;
CREATE INDEX ix_outbox_active
ON outbox (status, priority DESC, created_at, sequence_number);
This also does the ix_outbox_active rebuild
for SQL Server.
Rows already in the table keep the point in time DATETIME stored, but they
can read back with different microseconds. DATETIME stores steps of 1/300
second, and DATETIME2 shows them in full, so .123 reads back as .123333.
Code that compares against a timestamp it read before the conversion no longer
finds a match.
The processed_message table, which records the messages each handler has
handled, has a processed_at column that also stays DATETIME. Convert it the
same way, with ALTER TABLE processed_message ALTER COLUMN processed_at
DATETIME2 NULL. Until then, the cutoff for removing old records can be off by
up to 3 ms.
eventstore verify exits 2 when it cannot connect
protean eventstore verify used to exit 1 with a logged traceback when it
could not connect to the event store. Under --json, stdout stayed empty.
It now exits 2, the code for a usage or environment error:
- Under
--json, stdout holds one result envelope withstatusset toerrorand the connection error underdata.error. - Without
--json, the connection error is printed as one line on stderr, with no traceback.
A script that checked for exit 1 no longer gets it when the command cannot
connect. Other errors during the scan still exit 1 with a traceback. If a CI
job treated any non-zero exit as a failure, nothing changes for it.
Generated .proto files import each other
protean schema generate --format protobuf used to copy every value object
and entity an element referenced into that element's file, as a sibling
message. The referenced element also had its own file. When both were in the
same package, protoc rejected the tree with "already defined" when it
compiled the files together.
Each value object and entity is now defined only in its own file. A file that
uses one adds an import of that file, by its path under schemas/:
syntax = "proto3";
package shop.ordering;
import "Order/entities/Package.v1.proto";
message Order {
string id = 1;
repeated .shop.ordering.Package packages = 2;
}
What this changes for you:
- Compiling the files. Pass
-I <output>/schemastoprotoc, so it can find the imported files. This applies even when you compile one file at a time. - Type names. A referenced type is named by its full name with a leading
dot (
.shop.ordering.Package). A value object from another module keeps its own full name, such asshared.kernel.Money. It is no longer copied into the referencing element's package, so generated code that used the local copy (sales.ordering.Money) must useshared.kernel.Money. - Nested messages. A value object that belongs to no aggregate cluster,
such as one only projections use, has no file. Entities that refer to each
other through
HasOneorHasManywould import each other in a cycle. In both cases the type is defined as a nested message inside the file that uses it, such as.shop.ordering.CustomerCard.ShippingAddress. - Timestamps. Dates are written as
.google.protobuf.Timestamp, with a leading dot, so a package with agooglepart cannot shadow it. - Name clashes.
protean schema generatenow fails with an error when two elements would be written to the same file, in any format. Before, the second file silently replaced the first. Rename one of the two classes.
Field names, field numbers and labels are unchanged, so the bytes on the wire
are the same. Tools that compare message names, such as a schema registry's
compatibility check, Any type URLs and generated class paths, see the new
names.
generate_proto_schema() called without the new import_path argument, and
generate_proto_schemas(), still return one self-contained file per element
with sibling messages.
Deprecation versions must be strings
deprecated= used to accept a version written as a number and turn it into a
string. A number cannot keep a trailing zero, so 0.20 became "0.2". That
sorts before 0.18, so diff_ir(..., current_version="0.18") classed a field
due for removal in 0.20 as safe to remove. protean ir diff and the
protean-check-compat hook pass no current version, so they class every
deprecated removal as premature and were not affected. A declared
since: 0.20 showed as "deprecated since v0.2" in diagnostics.
deprecated= on an element, a field or a FieldSpec now raises
ConfigurationError when since or removal is not a string. removal: None
still means there is no removal version.
# fragment
# Accepted before and read as "0.2". Now raises ConfigurationError.
name = String(deprecated={"since": "0.18", "removal": 0.20})
Quote both versions:
from protean.fields import String
name = String(deprecated={"since": "0.18", "removal": "0.20"})
diff_ir() and classify_changes() now check every deprecated block in
both IR files before comparing them, on elements and on fields, whether or not
anything changed. They raise ConfigurationError naming the element or field
when:
sinceis not a string, orremovalis neither a string nornull.sinceis missing.- The block is not an object, such as
"deprecated": "0.15".
A current_version that is not a string raises too. Before, the diff read a
block only on a removed published event or a removed field. It read a numeric
version as text, and could crash with an AttributeError or KeyError on a
block that is not an object or has no since.
protean ir diff prints the error and exits 1. The protean-check-compat
hook prints it and blocks the commit, under strictness = "warn" too. With
strictness = "off", both skip the comparison.
A baseline that Protean generated never holds a number. The old code wrote
0.20 into it as the string "0.2", and the diff cannot tell that the value
was meant as 0.20.
To upgrade:
- Quote every
sinceandremovalversion indeprecated=. - Regenerate the IR baseline, so that a version that lost its zero, such as
"0.2", is written again as"0.20".
The IR's $schema URL moved
The IR used to name its schema https://protean.dev/ir/v{X}/schema.json, which
does not resolve. New IR files name
https://docs.proteanhq.com/assets/ir/v{X}/schema.json, where the docs site
serves every schema version. Each schema's $id moved to the same URL.
No action is needed. protean ir check and protean ir diff ignore $schema,
so the old URL alone does not make a baseline stale or show up as a change. It
picks up the new URL the next time you regenerate it. If a tool of yours matches
the $schema value, accept both URLs.
New in ir diff: value type changes on Dict and List fields
ir diff now compares the value type of a Dict or List field that keeps
its type. Moving a field from Dict() to
Dict(value_type=ValueObject(TierPrice)) used to show up only in the text
diff: the compatibility report listed nothing for it and the command exited
2. Rows stored while the field was untyped can fail to load once it is
typed.
Adding, changing or removing a value type is now a breaking
field_type_changed, and a published event lists it as
contract_field_type_changed. Under strictness = "strict", a diff that
exited 2 now exits 1, so a CI job that runs protean ir diff or the
protean-check-compat hook fails on a value type change. Under
strictness = "warn", it exits 0 and the hook passes. See
Dict and List value types.
Email port removed
The email subsystem was deprecated in 0.17, and 0.18 warned that it would be removed in 0.19.0. It is now removed. Sending email is an application concern, so Protean no longer ships a port for it.
These names are gone, and using them raises AttributeError or ImportError:
- The
@domain.emaildecorator and theDomainObjects.EMAILelement type. Domain.send_email(),Domain.get_email_provider()and theDomain.email_providersattribute.domain.registry.emails.BaseEmailandBaseEmailProvider(protean.core.email).EmailProviders,DummyEmailProviderandSendgridEmailProvider(protean.adapters.email, and the two names re-exported fromprotean.adapters).
The sendgrid extra is gone too, so pip install "protean[sendgrid]" no
longer installs the SendGrid client. If your code calls SendGrid directly,
install the sendgrid package yourself.
An [email_providers] block in domain.toml is now ignored. Creating the
Domain no longer warns about it, and protean check no longer reports it. You can
delete the block. An [email_providers] block inside an environment section
(for example [production.email_providers]) is still loaded when that
environment is active, so a ${VAR} placeholder in it must still resolve.
Delete those blocks too.
protean check no longer has the DEPRECATED_EMAIL and DEPRECATED_CONFIG
codes. A suppress_checks list or a [lint].suppressions table that still
names them keeps working, because nothing emits those codes now. You can drop
those entries.
To keep sending email, do it from an event handler or a subscriber that calls a notification service in your application:
# fragment
@domain.event_handler(part_of=User)
class WelcomeNotifier:
@handle(UserRegistered)
def send_welcome(self, event: UserRegistered) -> None:
notifications.send_welcome_email(event.email)
Here notifications is your own module. It can wrap SendGrid, SMTP or any
other service.
State-based aggregates save their events only through add()
A Unit of Work used to save the events of every state-based aggregate it had
read, whether or not add() was called on it. The aggregate's field changes
were saved only through add(). A handler that read an aggregate, called a
method that raised an event and never called add() stored the event, but the
database kept the old state:
# fragment
@handle(ConfirmOrder)
def confirm(self, command: ConfirmOrder) -> None:
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
order.confirm() # sets status and raises OrderConfirmed
# No repo.add(order): before 0.19, OrderConfirmed was saved and
# published, but status stayed unchanged in the database.
An aggregate's events are now saved only when add() has saved the aggregate
in the same Unit of Work. This is the rule event-sourced aggregates already
followed. Events raised on an aggregate that was only read are not saved at
commit. Protean logs nothing and raises nothing. An aggregate removed with
_dao.delete() does not count as saved either, so events raised on it before
the delete are not saved.
To keep the event, pass the aggregate to add(). That saves its field changes
too:
# fragment
@handle(ConfirmOrder)
def confirm(self, command: ConfirmOrder) -> None:
repo = current_domain.repository_for(Order)
order = repo.get(command.order_id)
order.confirm()
repo.add(order)
Call add() after the last change. An add() on an aggregate with no change
since it was read and no pending events writes nothing, so it does not keep
an event raised after it. If the aggregate changes again after add(), call
add() again.
Look for this pattern in command handlers, event handlers, application
services and process managers: an aggregate loaded with get() or a query, a
method called on it that raises an event, and no add() before the Unit of
Work ends. Tests that expect such an event in the event store or the outbox
fail after the upgrade, which is one way to find the places to fix.