Repository navigation
Async support - #114
Async support#114Enforcer wants to merge 16 commits into
Conversation
…uring universal tests
|
|
||
| async def delete_stream(self, stream_id: StreamId) -> None: | ||
| delete_events_stmt = delete(self._event_model).where( | ||
| self._event_model.stream_id == stream_id, |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Prexisting bug, simply copied to new context. addressed
| int, | ||
| await self._client.append_events( | ||
| str(name), | ||
| current_version=StreamState.ANY, |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
pre-existing bug, fixing in both places
| ) -> AsyncGenerator[RecordedRaw, None]: | ||
| raw = RawEvent( | ||
| uuid=UUID(entry.data["uuid"]), | ||
| stream_id=StreamId( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Old bug, present also in sync version. Fixed
| ) | ||
|
|
||
|
|
||
| class AsyncStorageStrategy: |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
general comment not specific for this file or class, dismissed here but applied where relevenat
| ) | ||
|
|
||
| @singledispatchmethod | ||
| def _wrap_events( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
unnecessary optimization, dismissed
| yield wrapped | ||
| await self._save(wrapped) | ||
|
|
||
| async def _load(self, wrapped: WrappedAggregate[TAggregate]) -> None: |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Nope, acceptable trade-off for me
| self, | ||
| timelimit: Seconds | timedelta, | ||
| ) -> AsyncIterator[Recorded | None]: | ||
| timelimit = SubscriptionBuilder._to_timedelta(timelimit) |
There was a problem hiding this comment.
[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.
| self._session = session | ||
| self._projector_cursor_model = projector_cursor_model | ||
|
|
||
| async def increment(self, name: str, stream_id: StreamId, version: int) -> None: |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Nope, acceptable trade off for me
…erently for kurrentDB
Support most major features, excludes Django (async support there is under development)