Skip to content

Commit 0d2bb16

Browse files
vanroguclaude
andauthored
Add optional snapshot support for live models (#24)
* Add implementation plan for optional live model snapshotting Design proposal that mirrors aggregate snapshotting techniques for live models (read models). Reuses SnapshotCapable and SnapshotStorage SPI, adds LiveModelSnapshotSpecification builder API, and modifies ProjectLiveModelCommand to load/save snapshots around projection. https://claude.ai/code/session_01CxJGnsJcX4mJtMxGHrWqH3 * Implement optional live model snapshotting with pluggable SnapshotStorage SPI Mirrors the aggregate snapshotting approach for live models (read models projected on-demand). Live models that implement SnapshotCapable<T> can now be configured with a SnapshotStorage at build time to cache their projected state, avoiding full event replay on subsequent reads. API changes: - SnapshotCapable: add key(String, Object...) overload for live model identity based on constructor params (existing Tags-based key unchanged) - LiveModelSpecification: add snapshots(SnapshotStorage) entry point - New LiveModelSnapshotSpecification: fluent builder with eventCountThreshold, readAndWrite/readOnly/writeOnly modes Implementation: - ProjectLiveModelCommand/Unbounded: load snapshot before projection (Projector.startingAfter), save after if threshold met - ReadModelModule: LiveModelInfo record holds per-model snapshot config and Micrometer counters (snapshot.read.count, snapshot.write.count) - LiveModelSnapshotSpecificationImpl: reuses SnapshotSpecificationImpl's READ_AND_OR_WRITE enum Tests: - LiveModelSnapshotTest: snapshot save/load lifecycle, version compatibility, readOnly/writeOnly modes, no-snapshot backward compat https://claude.ai/code/session_01CxJGnsJcX4mJtMxGHrWqH3 * Disambiguate null Tags arguments in SnapshotCapableTest Explicitly cast null to (Tags) in key() calls to avoid ambiguity with the new key(String, Object...) overload for live model snapshots. https://claude.ai/code/session_01CxJGnsJcX4mJtMxGHrWqH3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef5740b commit 0d2bb16

File tree

12 files changed

+1121
-72
lines changed

12 files changed

+1121
-72
lines changed

plan.md

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
# Implementation Plan: Optional Live Model Snapshotting
2+
3+
## Problem
4+
5+
Live models are rebuilt from scratch on every `read()` call by replaying all matching events through a `Projector`. For read models with large event histories, this becomes a performance bottleneck. Aggregates already solve this with snapshotting — we apply the same techniques to live models.
6+
7+
## Design Principles (mirroring aggregate snapshotting)
8+
9+
| Aggregate Snapshotting | Live Model Snapshotting (proposed) |
10+
|---|---|
11+
| `SnapshotCapable<T>` on aggregate | `SnapshotCapable<T>` on read model (reused) |
12+
| `SnapshotStorage<T>` SPI | Same `SnapshotStorage<T>` SPI (reused, no changes) |
13+
| `SnapshotSpecification` builder API | `LiveModelSnapshotSpecification` builder API (new, mirrors it) |
14+
| `AggregateSpecification.snapshots(storage)` | `LiveModelSpecification.snapshots(storage)` |
15+
| Key from `name + Tags` | Key from `readModelName + constructorParams` |
16+
| `AggregateContextImpl` does load/save | `ProjectLiveModelCommand` does load/save |
17+
| `AggregateModule` orchestrates | `ReadModelModule` orchestrates |
18+
| Event count threshold triggers save | Event count threshold triggers save |
19+
| `readAndWrite() / readOnly() / writeOnly()` | Same modes |
20+
| Micrometer metrics | Same pattern of metrics |
21+
22+
## Changes by Module
23+
24+
---
25+
26+
### 1. `sliceworkz-eventmodeling-api` (public API)
27+
28+
#### 1a. Modify `SnapshotCapable<T>` — add overloaded `key()` for constructor-param-based identity
29+
30+
**File:** `snapshots/SnapshotCapable.java`
31+
32+
Add a second `default` key method alongside the existing Tags-based one:
33+
34+
```java
35+
default String key(String name, Object... constructorParams) {
36+
StringBuilder keyBuilder = new StringBuilder(name != null ? name : "");
37+
if (constructorParams != null) {
38+
for (Object param : constructorParams) {
39+
keyBuilder.append("/");
40+
keyBuilder.append(param != null ? param.toString() : "");
41+
}
42+
}
43+
return keyBuilder.toString();
44+
}
45+
```
46+
47+
This keeps `SnapshotCapable` as the single marker interface for both aggregates and live models. The Tags-based `key(String, Tags)` remains untouched. Read models use the `key(String, Object...)` overload. Both can be overridden for custom key logic.
48+
49+
**Rationale:** Reusing `SnapshotCapable` rather than creating a new interface means:
50+
- Read models and aggregates share the same contract (`takeSnapshot`, `fromSnapshot`, `version`)
51+
- The `SnapshotStorage<T>` SPI needs zero changes
52+
- Existing `SnapshotSpecificationImpl` logic can be reused
53+
54+
#### 1b. New `LiveModelSnapshotSpecification<D,I,O>` interface
55+
56+
**File:** `snapshots/LiveModelSnapshotSpecification.java` (new)
57+
58+
```java
59+
public interface LiveModelSnapshotSpecification<DOMAIN_EVENT_TYPE, INBOUND_EVENT_TYPE, OUTBOUND_EVENT_TYPE> {
60+
61+
LiveModelSnapshotSpecification<D, I, O> eventCountThreshold(int threshold);
62+
63+
BoundedContextBuilder<D, I, O> readAndWrite();
64+
BoundedContextBuilder<D, I, O> readOnly();
65+
BoundedContextBuilder<D, I, O> writeOnly();
66+
}
67+
```
68+
69+
Mirrors `SnapshotSpecification` exactly, just returns to the builder via a different path. This is the fluent API returned after calling `.snapshots(storage)` on a live model registration.
70+
71+
#### 1c. Modify `LiveModelSpecification<D,I,O>` — add `snapshots()` entry point
72+
73+
**File:** `readmodels/LiveModelSpecification.java`
74+
75+
Add:
76+
```java
77+
<SNAPSHOT_TYPE> LiveModelSnapshotSpecification<D, I, O> snapshots(SnapshotStorage<SNAPSHOT_TYPE> snapshotStorage);
78+
```
79+
80+
This creates the same entry point pattern as `AggregateSpecification.snapshots(storage)`.
81+
82+
---
83+
84+
### 2. `sliceworkz-eventmodeling-impl` (implementation)
85+
86+
#### 2a. New `LiveModelSnapshotSpecificationImpl`
87+
88+
**File:** `module/snapshots/LiveModelSnapshotSpecificationImpl.java` (new)
89+
90+
Direct mirror of `SnapshotSpecificationImpl`. Holds:
91+
- `SnapshotStorage<SNAPSHOT_TYPE> snapshotStorage`
92+
- `READ_AND_OR_WRITE readAndOrWrite` (reuse the existing enum from `SnapshotSpecificationImpl`)
93+
- `int eventCountThreshold` (default 100)
94+
95+
Returns to `BoundedContextBuilder` on terminal methods.
96+
97+
#### 2b. Modify `BoundedContextBuilderImpl.LiveModelSpecificationImpl`
98+
99+
**File:** `module/boundedcontext/BoundedContextBuilderImpl.java`
100+
101+
Add snapshot state to the inner class:
102+
103+
```java
104+
class LiveModelSpecificationImpl implements LiveModelSpecification<D, I, O> {
105+
// existing fields...
106+
private LiveModelSnapshotSpecificationImpl<?,D,I,O> snapshotSpecification;
107+
108+
@Override
109+
public <SNAPSHOT_TYPE> LiveModelSnapshotSpecification<D, I, O> snapshots(
110+
SnapshotStorage<SNAPSHOT_TYPE> snapshotStorage) {
111+
this.snapshotSpecification = new LiveModelSnapshotSpecificationImpl<>(builder, snapshotStorage);
112+
return snapshotSpecification;
113+
}
114+
115+
// Accessor methods for ReadModelModule to use:
116+
public SnapshotStorage<Object> snapshotStorage() { ... }
117+
public boolean readSnapshots() { ... }
118+
public boolean writeSnapshots() { ... }
119+
public int snapshotEventCountThreshold() { ... }
120+
}
121+
```
122+
123+
#### 2c. Modify `ReadModelModule` — pass snapshot config, store per-readmodel info
124+
125+
**File:** `module/readmodels/ReadModelModule.java`
126+
127+
The constructor and `liveModel()` method need to know about snapshot configuration per live model class. Instead of just holding `Collection<Class<?>>` for live models, hold a map of `LiveModelInfo` records (same pattern as `AggregateModule.AggregateInfo`):
128+
129+
```java
130+
record LiveModelInfo<DOMAIN_EVENT_TYPE>(
131+
Class<? extends ReadModelWithMetaData<DOMAIN_EVENT_TYPE>> readModelClass,
132+
SnapshotStorage<Object> snapshotStorage,
133+
boolean readSnapshots,
134+
boolean writeSnapshots,
135+
int snapshotEventCountThreshold,
136+
Counter counterSnapshotRead,
137+
Counter counterSnapshotWrite
138+
) { }
139+
```
140+
141+
The `liveModel()` method passes snapshot config to `ProjectLiveModelCommand`.
142+
143+
#### 2d. Modify `ProjectLiveModelCommand` — the core change
144+
145+
**File:** `module/readmodels/ProjectLiveModelCommand.java`
146+
147+
This is the equivalent of `AggregateContextImpl` + `AggregateModule.aggregate()` for live models. The `execute()` method changes from:
148+
149+
```java
150+
// BEFORE:
151+
readModel = instantiate(readModelClass, constructorParams);
152+
Projector.from(eventSource).towards(readModel).build().run();
153+
```
154+
155+
To:
156+
157+
```java
158+
// AFTER:
159+
readModel = instantiate(readModelClass, constructorParams);
160+
161+
EventReference lastEventReference = null;
162+
163+
// 1. LOAD snapshot (if configured and read model implements SnapshotCapable)
164+
if (readSnapshots && readModel instanceof SnapshotCapable<?> snapshotCapable) {
165+
String key = snapshotCapable.key(readModel.readmodelName(), constructorParams);
166+
String version = snapshotCapable.version();
167+
var loaded = snapshotStorage.load(key, version);
168+
if (loaded.isPresent()) {
169+
snapshotCapable.fromSnapshot(loaded.get().snapshot());
170+
lastEventReference = loaded.get().lastEventReference();
171+
counterSnapshotRead.increment();
172+
}
173+
}
174+
175+
// 2. Replay remaining events from after snapshot
176+
Projector<D> projector = Projector.from(eventSource)
177+
.towards(readModel)
178+
.startingAfter(lastEventReference) // <-- key: resume from snapshot point
179+
.build();
180+
ProjectorMetrics metrics = projector.run();
181+
182+
// 3. SAVE snapshot (if configured and threshold met)
183+
if (writeSnapshots && readModel instanceof SnapshotCapable<?> snapshotCapable) {
184+
long totalEvents = (lastEventReference != null ? 1 : 0) + metrics.eventsStreamed();
185+
if (metrics.eventsStreamed() >= snapshotEventCountThreshold) {
186+
String key = snapshotCapable.key(readModel.readmodelName(), constructorParams);
187+
snapshotStorage.save(key, snapshotCapable.version(),
188+
snapshotCapable.takeSnapshot(), metrics.lastEventReference());
189+
counterSnapshotWrite.increment();
190+
}
191+
}
192+
```
193+
194+
The same logic applies to `ProjectLiveModelUnboundedCommand`.
195+
196+
#### 2e. Wire snapshot config through `BoundedContextBuilderImpl.build()`
197+
198+
**File:** `module/boundedcontext/BoundedContextBuilderImpl.java`
199+
200+
Change the `build()` method to pass `LiveModelSpecificationImpl` objects (not just classes) to `ReadModelModule`, so it has access to snapshot configuration:
201+
202+
```java
203+
// BEFORE:
204+
Collection<Class<?>> liveModelClasses = liveModelSpecs.stream()
205+
.map(LiveModelSpecificationImpl::readModelClass)
206+
.collect(...);
207+
new ReadModelModule<>(..., liveModelClasses, ...);
208+
209+
// AFTER:
210+
new ReadModelModule<>(..., liveModelSpecs, ...);
211+
```
212+
213+
---
214+
215+
### 3. `sliceworkz-eventmodeling-testing` — no changes needed
216+
217+
The existing test infrastructure supports the pattern. Mock snapshot storages are test-local.
218+
219+
---
220+
221+
### 4. `sliceworkz-eventmodeling-tests-inmem` — new test class
222+
223+
#### New: `LiveModelSnapshotTest`
224+
225+
**File:** `module/readmodels/LiveModelSnapshotTest.java` (new)
226+
227+
Mirrors `AggregateCapabilityTest` for live models:
228+
229+
1. **`testLiveModelSnapshots()`** — Create a live read model that implements `SnapshotCapable`. Append N events, read the model. Verify snapshot is saved. Read again, verify snapshot is loaded and only delta events are replayed.
230+
231+
2. **`testLiveModelSnapshotsChangingVersion()`** — Same as aggregate test: changing version causes snapshot miss, full replay.
232+
233+
3. **`testLiveModelWithoutSnapshots()`** — Verify that live models without `SnapshotCapable` work exactly as before (no regression).
234+
235+
4. **`testLiveModelSnapshotReadOnly()`** / **`testLiveModelSnapshotWriteOnly()`** — Test the modes.
236+
237+
A mock read model + mock snapshot storage following the exact same pattern as `MockAggregate` + `MockSnapshotStorage` in the aggregate tests.
238+
239+
---
240+
241+
## Builder API Usage (end-user perspective)
242+
243+
### Without snapshots (unchanged):
244+
```java
245+
BoundedContext.newBuilder(...)
246+
.readmodel(AccountDetailsReadModel.class).live()
247+
.build();
248+
```
249+
250+
### With snapshots (new):
251+
```java
252+
SnapshotStorage<AccountDetailsSnapshot> snapshotStorage = new InMemorySnapshotStorage<>();
253+
// or: new PostgresSnapshotStorage<>(dataSource);
254+
// or: any custom SnapshotStorage implementation
255+
256+
BoundedContext.newBuilder(...)
257+
.readmodel(AccountDetailsReadModel.class)
258+
.snapshots(snapshotStorage)
259+
.eventCountThreshold(200)
260+
.readAndWrite() // returns to builder (terminal)
261+
.build();
262+
```
263+
264+
### Read model implementation:
265+
```java
266+
public class AccountDetailsReadModel
267+
implements ReadModel<BankingDomainEvent>, SnapshotCapable<AccountDetailsSnapshot> {
268+
269+
private DomainConceptId accountId;
270+
private AccountDetails account;
271+
272+
public AccountDetailsReadModel(DomainConceptId accountId) {
273+
this.accountId = accountId;
274+
}
275+
276+
// --- ReadModel methods (unchanged) ---
277+
@Override public EventQuery eventQuery() { ... }
278+
@Override public void when(BankingDomainEvent event) { ... }
279+
public Optional<AccountDetails> getAccountDetails() { ... }
280+
281+
// --- SnapshotCapable methods (new) ---
282+
@Override
283+
public AccountDetailsSnapshot takeSnapshot() {
284+
return new AccountDetailsSnapshot(account);
285+
}
286+
287+
@Override
288+
public void fromSnapshot(AccountDetailsSnapshot snapshot) {
289+
this.account = snapshot.accountDetails();
290+
}
291+
292+
@Override
293+
public String version() { return "v1"; }
294+
295+
// key() uses default implementation: "AccountDetailsReadModel/<accountId>"
296+
}
297+
```
298+
299+
## Snapshot Key Strategy
300+
301+
- Aggregates: `key(name, Tags)``"AggregateName/tagKey-tagValue/..."`
302+
- Live models: `key(name, Object...)``"ReadModelName/param1.toString()/param2.toString()/..."`
303+
- Both overridable by the user for custom key schemes
304+
- Version matching identical: exact match required, mismatch = full replay
305+
306+
## Metrics (mirroring aggregates)
307+
308+
| Metric | Tags | Purpose |
309+
|--------|------|---------|
310+
| `sliceworkz.eventmodeling.readmodel.live.snapshot.read.count` | context, readmodel | Snapshot loads |
311+
| `sliceworkz.eventmodeling.readmodel.live.snapshot.write.count` | context, readmodel | Snapshot saves |
312+
313+
Existing `sliceworkz.eventmodeling.readmodel.live.render` and `sliceworkz.eventmodeling.readmodel.live.duration` metrics remain unchanged.
314+
315+
## Files Changed Summary
316+
317+
| Module | File | Change |
318+
|--------|------|--------|
319+
| api | `snapshots/SnapshotCapable.java` | Add `key(String, Object...)` default method |
320+
| api | `snapshots/LiveModelSnapshotSpecification.java` | **New** — builder spec interface |
321+
| api | `readmodels/LiveModelSpecification.java` | Add `snapshots()` method |
322+
| impl | `module/snapshots/LiveModelSnapshotSpecificationImpl.java` | **New** — mirrors `SnapshotSpecificationImpl` |
323+
| impl | `module/boundedcontext/BoundedContextBuilderImpl.java` | Add snapshot fields to `LiveModelSpecificationImpl`, wire through `build()` |
324+
| impl | `module/readmodels/ReadModelModule.java` | Hold `LiveModelInfo` instead of just classes, pass to commands |
325+
| impl | `module/readmodels/ProjectLiveModelCommand.java` | Load/save snapshot around projection |
326+
| impl | `module/readmodels/ProjectLiveModelUnboundedCommand.java` | Same as above |
327+
| tests-inmem | `module/readmodels/LiveModelSnapshotTest.java` | **New** — test class mirroring `AggregateCapabilityTest` |
328+
329+
## What Stays Unchanged
330+
331+
- `SnapshotStorage<T>` interface — fully reused, zero changes
332+
- `SnapshotSpecification` — aggregate-specific, untouched
333+
- `SnapshotSpecificationImpl` — aggregate-specific, untouched (but `READ_AND_OR_WRITE` enum reused)
334+
- `AggregateModule`, `AggregateContextImpl` — untouched
335+
- Existing read model behavior without snapshots — fully backward compatible
336+
- `BoundedContext.read()` / `readUnbounded()` — signature unchanged

sliceworkz-eventmodeling-api/src/main/java/org/sliceworkz/eventmodeling/readmodels/LiveModelSpecification.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
package org.sliceworkz.eventmodeling.readmodels;
1919

2020
import org.sliceworkz.eventmodeling.boundedcontext.BoundedContextBuilder;
21+
import org.sliceworkz.eventmodeling.snapshots.LiveModelSnapshotSpecification;
22+
import org.sliceworkz.eventmodeling.snapshots.SnapshotStorage;
2123

2224
public interface LiveModelSpecification<DOMAIN_EVENT_TYPE, INBOUND_EVENT_TYPE, OUTBOUND_EVENT_TYPE> {
2325

@@ -27,4 +29,17 @@ public interface LiveModelSpecification<DOMAIN_EVENT_TYPE, INBOUND_EVENT_TYPE, O
2729

2830
BoundedContextBuilder<DOMAIN_EVENT_TYPE, INBOUND_EVENT_TYPE, OUTBOUND_EVENT_TYPE> eventuallyConsistent();
2931

32+
/**
33+
* Configures snapshotting for this live model to optimize projection performance.
34+
* <p>
35+
* Snapshots store the complete state of a live model at a point in time, allowing
36+
* faster projection by avoiding replay of the entire event history. The live model
37+
* must implement {@link org.sliceworkz.eventmodeling.snapshots.SnapshotCapable} to use this feature.
38+
*
39+
* @param <SNAPSHOT_TYPE> the type representing the live model's snapshot state
40+
* @param snapshotStorage the storage mechanism for persisting and loading snapshots
41+
* @return a snapshot specification for further configuration
42+
*/
43+
<SNAPSHOT_TYPE> LiveModelSnapshotSpecification<DOMAIN_EVENT_TYPE, INBOUND_EVENT_TYPE, OUTBOUND_EVENT_TYPE> snapshots ( SnapshotStorage<SNAPSHOT_TYPE> snapshotStorage );
44+
3045
}

0 commit comments

Comments
 (0)