Function: appendbatch in event-sourcing-store/eventstore.py

Date: 2026-05-29

Time: 11:11

EventStore.append_batch

Purpose

append_batch writes multiple events to a single stream in one logical operation. It exists to support domain operations that produce several events atomically — for example, a checkout that emits both OrderPlaced and InventoryReserved. The key difference from calling append in a loop: subscribers are notified *after* all events are stored, not interleaved with writes.

Contract

Preconditions:

Postconditions:

Invariant (intended but not enforced): event_id values are globally unique and sequential (1-indexed). This holds only under single-threaded access.

Parameters

| Parameter | Type | Description |

|-----------|------|-------------|

| stream_id | str | Logical stream to append to (e.g., "order-123") |

| events | list[tuple[str, dict]] | Batch of (event_type, data) pairs |

| expected_version | Optional[int] | Optimistic concurrency guard. If set, the stream must have exactly this many events, or ConcurrencyConflict is raised. Defaults to None (no check). |

Edge cases: if events is empty, no version check side-effects occur (the check still runs if expected_version is set), and an empty list is returned.

Return Value

list[Event] — the newly created Event objects in insertion order, with their assigned event_id, timestamp, and all fields populated. The caller gets back the same objects that are now in the store's internal list, so mutating them would corrupt store state (no defensive copy is made).

Algorithm

1. Optimistic concurrency check — If expected_version is not None, read the stream's current version (count of events in that stream). If it doesn't match, raise ConcurrencyConflict immediately, before any mutation.

2. Event creation loop — For each (event_type, data) tuple:

3. Subscriber notification — After all events are stored, iterate over result and call every subscriber with each event. This is the critical ordering distinction from append: subscribers see a consistent state where the entire batch is already in the store.

Side Effects

Error Handling

| Exception | When | State after |

|-----------|------|-------------|

| ConcurrencyConflict | expected_version doesn't match current stream version | No mutation — raised before the loop |

| KeyError / ValueError | If an element in events isn't a 2-tuple | Partial mutation — events already appended before the bad tuple stay in the store |

| Subscriber exception | A subscriber raises during notification | All events are stored; only notification is incomplete |

There is no rollback mechanism. If the loop fails partway through (e.g., disk write error, bad tuple), events already appended are permanent. The "atomicity" in the docstring is logical (subscriber notification is deferred), not transactional.

Usage Patterns


store = EventStore()

# Append a batch with concurrency guard
store.append_batch("cart-42", [
    ("ItemAdded", {"sku": "A1", "qty": 1}),
    ("ItemAdded", {"sku": "B2", "qty": 3}),
], expected_version=0)

# Without concurrency check
store.append_batch("cart-42", [
    ("CartCheckedOut", {"total": 59.99}),
])

Callers typically use expected_version when replaying a command that must not conflict with concurrent writes. Without it, appends are unconditional.

Dependencies

Assumptions Not Enforced by the Type System

1. Single-threaded accesseventid = len(self.events) + 1 is a race condition under concurrent access. No locking exists.

2. Mutable return — returned Event objects are the same references stored internally. Callers must not mutate them.

3. data is not copied — the dict from each input tuple is stored directly in the Event. If the caller mutates it afterward, the stored event changes.

4. Subscriber ordering — subscribers see events in batch order, but nothing prevents a subscriber from appending more events to the store, which could interleave with the notification loop's view of state.

5. Disk persistence is not atomic — despite the docstring's "atomically," a crash mid-batch produces a partial file. Recovery via loadfrom_file would load the partial batch with no indication it's incomplete.

Topics to Explore

Beliefs