Skip to content

Async support - #114

Open
Enforcer wants to merge 16 commits into
mainfrom
async-support
Open

Enforcer wants to merge 16 commits into
mainfrom
async-support

Conversation

@Enforcer

Copy link
Copy Markdown
Collaborator

Support most major features, excludes Django (async support there is under development)

@lzukowski
lzukowski self-requested a review September 9, 2026 14:33

async def delete_stream(self, stream_id: StreamId) -> None:
delete_events_stmt = delete(self._event_model).where(
self._event_model.stream_id == stream_id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Scope destructive operations to the current tenant. Both delete statements match only stream_id, so deleting a stream through a tenant-scoped backend also deletes copies belonging to every other tenant. I reproduced this with two tenants sharing one ID: the second tenant had one event before first.delete_stream(...) and zero afterwards. The snapshot lookup above has the same missing _tenant_id predicate and can raise MultipleResultsFound. Please include the tenant in every mutation/lookup here. The async in-memory strategy has the analogous cross-tenant deletion problem.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prexisting bug, simply copied to new context. addressed

int,
await self._client.append_events(
str(name),
current_version=StreamState.ANY,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Apply expected_version atomically in this append. _ensure_stream() reads the current revision separately, then this call appends with StreamState.ANY. Two writers can therefore read the same revision, both pass validation, and both append successfully. AsyncKurrentDBClient.append_events() accepts a concrete current_version; pass the expected stream revision there and translate WrongCurrentVersionError to ConcurrentStreamWriteError.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked completely - also, totally old bug in previous implementation


versioning.validate_if_compatible(self._storage.get_version(stream_id))

if versioning is not NO_VERSIONING and versioning.expected_version:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not use truthiness for expected_version. Zero is the valid expected version for a new stream, but this condition also makes a second append with the default expected_version=0 skip validation. Reproduction: two consecutive append(Event(), stream_id=...) calls succeed and load as versions [1, 1], while the versioning docs say the second call must fail. Compare explicitly instead of treating zero as absent. The async KurrentDB strategy repeats the same condition.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long pre-existing a bug in kurrentdb and in-memory implementation of versioning.
Reworked concurrency control and added more tests for all backends. Extended documentation

RecordedRaw(entry=raw, position=position, tenant_id=self._tenant_id)
for position, raw in enumerate(events, start=position + 1)
]
self._storage.append(records)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Avoid persisting before a known-unsupported dispatcher path can fail. With async key storage and a matching in-transaction listener, _SyncDispatcherSerde raises TypeError, but this storage mutation (and the outbox mutation) has already happened. I reproduced an append that raised TypeError while the stream still contained the event. Reject this configuration/listener combination before append, or otherwise validate/deserialize before mutating storage so a failed append cannot commit partially.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-existing bug, fixing in both places

) -> AsyncGenerator[RecordedRaw, None]:
raw = RawEvent(
uuid=UUID(entry.data["uuid"]),
stream_id=StreamId(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve StreamId.category in the outbox payload. put_into_outbox() stores the UUID and name but not the category, so this reconstruction always produces category=None. I reproduced an orders event being published with [None] as its observed category. Persist the category and pass it to this constructor; otherwise consumers cannot route outbox records by stream category.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Old bug, present also in sync version. Fixed

)


class AsyncStorageStrategy:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the current sibling interface shape and apply it consistently. Separate contracts such as StorageStrategy (def) and AsyncStorageStrategy (async def) are correct; they can retain the same semantic method names, and each backend should implement one complete contract. Apply the same pattern to Backend/AsyncBackend and Serde/AsyncSerde, sharing only a neutral container base and pure helpers—not a sync behavior-bearing superclass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

general comment not specific for this file or class, dismissed here but applied where relevenat

)

@singledispatchmethod
def _wrap_events(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Extract a shared functional core from the two event stores. After normalizing async/await, EventStore and AsyncEventStore are 99.6% identical. Event wrapping and Versioning construction are pure and should have one implementation; only serde and storage execution need separate sync and async orchestration. This avoids both duplication and a maybe_await abstraction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary optimization, dismissed

yield wrapped
await self._save(wrapped)

async def _load(self, wrapped: WrappedAggregate[TAggregate]) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Share repository replay and pending-event preparation. These paths are equivalent after removing await, despite the docstring saying the logic is shared. Extract pure helpers for replaying a loaded stream and building pending WrappedEvent values; keep only the context manager and event-store I/O separate.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, acceptable trade-off for me

self,
timelimit: Seconds | timedelta,
) -> AsyncIterator[Recorded | None]:
timelimit = SubscriptionBuilder._to_timedelta(timelimit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not call a private sync-builder method from async code. _to_timedelta() is pure configuration logic. Move it to a shared module-level helper, or introduce a shared SubscriptionSpec, so both builders depend on common data rather than the async API depending on the concrete sync builder.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed

self._session = session
self._projector_cursor_model = projector_cursor_model

async def increment(self, name: str, stream_id: StreamId, version: int) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Extract the common SQLAlchemy cursor core. This DAO duplicates the sync implementation with await added. Move statement construction and cursor-state classification into shared code; keep only Session.execute(...) versus await AsyncSession.execute(...) in thin adapters. Cursor/version fixes would then have one source of truth.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, acceptable trade off for me

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants