Skip to content

Commit 29f8db0

Browse files
vanroguclaude
andcommitted
updated docs for eventstore 0.6.3: savepoint pattern with initQuery, projection initialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ac6ce93 commit 29f8db0

3 files changed

Lines changed: 295 additions & 1 deletion

File tree

_posts/2025-11-09-eventstore-quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Add the EventStore BOM to your project pom.xml to manage dependency versions:
3232
```xml
3333
...
3434
<properties>
35-
<sliceworkz.eventstore.version>0.6.2</sliceworkz.eventstore.version>
35+
<sliceworkz.eventstore.version>0.6.3</sliceworkz.eventstore.version>
3636
</properties>
3737
...
3838
<dependencyManagement>

_posts/2025-11-29-eventstore-projecting-events.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,57 @@ With this configuration:
327327
- **During operation**: New appends trigger automatic incremental updates
328328
- **After each update**: The bookmark is saved, enabling seamless recovery
329329

330+
## Initializing Projections with Savepoints
331+
332+
For projections that are created on-the-fly — for example, to answer a query in an API request — replaying the entire event history on every instantiation can be expensive. The **savepoint pattern** addresses this by allowing projections to initialize from a recent snapshot event rather than replaying from the beginning.
333+
334+
A projection can optionally implement `initQuery()` to find the most recent savepoint event before the main `eventQuery()` runs:
335+
336+
```java
337+
public class StockLevelProjection implements Projection<StockEvent> {
338+
private final String product;
339+
private int level = 0;
340+
341+
public StockLevelProjection(String product) {
342+
this.product = product;
343+
}
344+
345+
@Override
346+
public EventQuery initQuery() {
347+
return EventQuery.forEvents(
348+
EventTypesFilter.of(StockCounted.class),
349+
Tags.of("product", product)
350+
).backwards().limit(1);
351+
}
352+
353+
@Override
354+
public EventQuery eventQuery() {
355+
return EventQuery.forEvents(
356+
EventTypesFilter.of(StockAdded.class, StockPicked.class),
357+
Tags.of("product", product)
358+
);
359+
}
360+
361+
@Override
362+
public void when(Event<StockEvent> event) {
363+
switch (event.data()) {
364+
case StockCounted c -> level = c.counted();
365+
case StockAdded a -> level += a.quantity();
366+
case StockPicked p -> level -= p.quantity();
367+
}
368+
}
369+
370+
public int level() { return level; }
371+
}
372+
```
373+
374+
The `initQuery()` runs once on the first projector execution. If a savepoint is found, the projection initializes from it and the main `eventQuery()` processes only the events that occurred after. When no savepoint exists, the pattern degrades gracefully — the full stream is replayed.
375+
376+
> When bookmarking is enabled, `initQuery()` is ignored. Bookmarked projections track their own position and must process every event.
377+
{: .prompt-warning }
378+
379+
For a detailed discussion of the savepoint pattern — including design decisions, comparison with bookmarking, and strategies for when to create savepoints — see the dedicated [Savepoint Pattern](/posts/eventstore-savepoint-pattern/) article.
380+
330381
## Interpreting Metrics
331382

332383
The Projector returns `ProjectorMetrics` containing detailed statistics about projection execution:
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
---
2+
layout: post
3+
toc: true
4+
title: The Savepoint Pattern
5+
description: Efficient projection initialization using savepoint events
6+
date: 2026-02-14 01:00:00
7+
categories: [Eventstore Documentation,Eventstore Application Scenarios]
8+
tags: [projection,projector,read model,savepoint,initQuery]
9+
---
10+
11+
# The Savepoint Pattern
12+
13+
This guide covers the savepoint pattern — a technique for avoiding full event replays when initializing projections, using the `initQuery()` capability introduced in EventStore 0.6.3.
14+
15+
## The Problem: Full Replays
16+
17+
Projections build read models by processing events sequentially. When a projection is initialized from scratch, it replays every event from the beginning of the stream:
18+
19+
```java
20+
StockLevelProjection projection = new StockLevelProjection("WIDGET-42");
21+
Projector.from(stream).towards(projection).build().run();
22+
// Processes ALL events from the beginning of time
23+
```
24+
25+
For streams with thousands or millions of events, this can be slow. Every time a new projection instance is created — after a restart, on a new server, or simply when handling a request — the entire history must be replayed.
26+
27+
Bookmarking solves this for long-lived projections that persist their position. But not every projection needs or wants that infrastructure. Some projections are short-lived, created on-the-fly to answer a specific question and then discarded. For these, replaying the full stream on every instantiation is wasteful.
28+
29+
## Savepoints: A Domain-Driven Solution
30+
31+
A **savepoint** is a regular domain event that summarizes the state of a projection at a point in time. It is not a framework construct — it is a pure business event, appended by your application whenever you decide it makes sense.
32+
33+
Consider a stock keeping scenario with these events:
34+
35+
```java
36+
sealed interface StockEvent {
37+
38+
record StockAdded(String product, int quantity) implements StockEvent { }
39+
40+
record StockPicked(String product, int quantity) implements StockEvent { }
41+
42+
/**
43+
* Savepoint event that summarizes the stock count at a certain moment in time.
44+
* This is a pure domain event — no special framework support needed.
45+
*/
46+
record StockCounted(String product, int counted) implements StockEvent { }
47+
48+
}
49+
```
50+
51+
`StockAdded` and `StockPicked` are individual stock movements. `StockCounted` is the savepoint — it captures the stock level at a moment in time. Think of it as a physical stock count in a warehouse: someone counts what is on the shelf and records the number.
52+
53+
With a savepoint in the stream, a new projection does not need to replay every movement from the beginning. It can jump to the most recent savepoint, initialize from there, and then process only the movements that came after.
54+
55+
## Using initQuery
56+
57+
The `Projection` interface provides an optional `initQuery()` method that enables this pattern:
58+
59+
```java
60+
static class StockLevelProjection implements Projection<StockEvent> {
61+
62+
private final String product;
63+
private int level = 0;
64+
65+
public StockLevelProjection(String product) {
66+
this.product = product;
67+
}
68+
69+
@Override
70+
public EventQuery initQuery() {
71+
// Find the last stock count (savepoint) for this product
72+
return EventQuery.forEvents(
73+
EventTypesFilter.of(StockCounted.class),
74+
Tags.of("product", product)
75+
).backwards().limit(1);
76+
}
77+
78+
@Override
79+
public EventQuery eventQuery() {
80+
// Only process movements — savepoints are handled exclusively by initQuery
81+
return EventQuery.forEvents(
82+
EventTypesFilter.of(StockAdded.class, StockPicked.class),
83+
Tags.of("product", product)
84+
);
85+
}
86+
87+
@Override
88+
public void when(Event<StockEvent> event) {
89+
switch (event.data()) {
90+
case StockCounted c -> level = c.counted();
91+
case StockAdded a -> level += a.quantity();
92+
case StockPicked p -> level -= p.quantity();
93+
}
94+
}
95+
96+
public int level() {
97+
return level;
98+
}
99+
100+
}
101+
```
102+
103+
The key elements:
104+
105+
- **`initQuery()`** returns a backward query with limit 1 — it finds the most recent `StockCounted` event for this product
106+
- **`eventQuery()`** returns the movements (`StockAdded`, `StockPicked`) — but **not** `StockCounted`
107+
- **`when()`** handles all three event types, since it receives events from both queries
108+
109+
## How the Projector Executes It
110+
111+
When the Projector runs a projection that has an `initQuery()`, the execution proceeds in two phases:
112+
113+
1. **Phase 1 — Initialization**: The `initQuery()` is executed. If a savepoint is found, it is passed to the `when()` handler to initialize state. The savepoint's event reference becomes the cursor position.
114+
115+
2. **Phase 2 — Delta processing**: The `eventQuery()` is executed starting from the cursor position set by the initQuery. Only events after the savepoint are processed.
116+
117+
On subsequent runs of the same Projector instance, the initQuery is skipped — the projector already has a cursor position and continues from there.
118+
119+
## A Full Example
120+
121+
Here is a complete walkthrough showing the savepoint pattern in action:
122+
123+
```java
124+
EventStore eventstore = InMemoryEventStorage.newBuilder().buildStore();
125+
126+
EventStreamId streamId = EventStreamId.forContext("warehouse");
127+
EventStream<StockEvent> stream = eventstore.getEventStream(streamId, StockEvent.class);
128+
129+
String product = "WIDGET-42";
130+
Tags tags = Tags.of("product", product);
131+
132+
// Simulate stock movements
133+
stream.append(AppendCriteria.none(), Event.of(new StockAdded(product, 100), tags));
134+
stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 10), tags));
135+
stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 5), tags));
136+
stream.append(AppendCriteria.none(), Event.of(new StockAdded(product, 50), tags));
137+
stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 20), tags));
138+
```
139+
140+
### First run — no savepoint exists
141+
142+
```java
143+
StockLevelProjection projection = new StockLevelProjection(product);
144+
ProjectorMetrics metrics = Projector.from(stream).towards(projection).build().run();
145+
146+
System.out.println("Stock level: " + projection.level()); // 115
147+
System.out.println("Events handled: " + metrics.eventsHandled()); // 5
148+
```
149+
150+
No savepoint exists yet, so the `initQuery()` returns nothing and the `eventQuery()` replays all five movements from the beginning. The pattern degrades gracefully — no special handling needed.
151+
152+
### Append a savepoint
153+
154+
```java
155+
stream.append(AppendCriteria.none(), Event.of(new StockCounted(product, 115), tags));
156+
```
157+
158+
This records the current stock level as a domain event. It is the application's choice when to do this — after a batch of operations, on a schedule, or triggered by a manual stock count.
159+
160+
### Second run — savepoint found
161+
162+
```java
163+
// More movements after the savepoint
164+
stream.append(AppendCriteria.none(), Event.of(new StockAdded(product, 30), tags));
165+
stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 12), tags));
166+
167+
StockLevelProjection projection2 = new StockLevelProjection(product);
168+
ProjectorMetrics metrics2 = Projector.from(stream).towards(projection2).build().run();
169+
170+
System.out.println("Stock level: " + projection2.level()); // 133
171+
System.out.println("Events handled: " + metrics2.eventsHandled()); // 3
172+
System.out.println("Queries done: " + metrics2.queriesDone()); // 2
173+
```
174+
175+
This time the `initQuery()` finds the `StockCounted(115)` savepoint. The projection initializes with `level = 115`, then processes only the two movements after it. Three events handled total (1 savepoint + 2 movements), two queries done (1 initQuery + 1 eventQuery) — instead of replaying all eight events from the beginning.
176+
177+
### Fixing a bad savepoint
178+
179+
```java
180+
stream.append(AppendCriteria.none(), Event.of(new StockCounted(product, 133), tags));
181+
stream.append(AppendCriteria.none(), Event.of(new StockPicked(product, 3), tags));
182+
183+
StockLevelProjection projection3 = new StockLevelProjection(product);
184+
ProjectorMetrics metrics3 = Projector.from(stream).towards(projection3).build().run();
185+
186+
System.out.println("Stock level: " + projection3.level()); // 130
187+
System.out.println("Events handled: " + metrics3.eventsHandled()); // 2
188+
```
189+
190+
The `initQuery()` always finds the most recent savepoint. If a previous savepoint was incorrect, just append a corrected one — the next projection run picks it up automatically.
191+
192+
## Design Decisions
193+
194+
### Keep initQuery and eventQuery separate
195+
196+
The `StockCounted` event type appears **only** in the `initQuery()`, not in the `eventQuery()`. This is intentional:
197+
198+
- The main query never processes savepoint events, which protects against buggy historical savepoints
199+
- There is no risk of double-processing a savepoint that happens to fall within the eventQuery range
200+
- Each query has a clear responsibility: initQuery initializes, eventQuery processes deltas
201+
202+
### Savepoints are pure domain events
203+
204+
Savepoints are ordinary events appended to the stream with `stream.append()`. There is no special framework API or savepoint-specific storage. This means:
205+
206+
- You decide when savepoints are created
207+
- You decide what data they contain
208+
- They are visible in queries like any other event
209+
- They participate in the normal event history
210+
211+
### Graceful degradation
212+
213+
When no savepoint exists — for example, on the very first run before any savepoint has been appended — the `initQuery()` returns nothing and the main `eventQuery()` replays from the beginning. No special configuration or fallback logic is needed.
214+
215+
## Savepoints vs Bookmarking
216+
217+
Both savepoints and bookmarking address the problem of avoiding full replays, but they serve different purposes:
218+
219+
| | Savepoint Pattern | Bookmarking |
220+
|---|---|---|
221+
| **State storage** | Domain events in the stream | Separate bookmark record |
222+
| **First run** | Jumps to last savepoint | Replays from beginning |
223+
| **Created by** | Application logic | Projector framework |
224+
| **Use case** | On-demand projections, live models | Long-lived persistent projections |
225+
| **Requires persistence** | No (savepoints are in the stream) | Yes (bookmark storage) |
226+
227+
> When bookmarking is enabled on the Projector, the `initQuery()` is ignored. Bookmarked projections track their own position and must process every event to stay consistent. If both are configured, a warning is logged at build time.
228+
{: .prompt-warning }
229+
230+
The savepoint pattern is particularly well-suited for projections that are created on-the-fly — for example, to answer a specific query in an API request. Instead of maintaining a long-lived bookmarked projection, you instantiate a projection, let it catch up from the latest savepoint, and discard it when you are done.
231+
232+
## When to Create Savepoints
233+
234+
The choice of when to append savepoint events is entirely up to the application. Some common strategies:
235+
236+
- **Periodic**: Append a savepoint every N events or on a time schedule
237+
- **On demand**: Append a savepoint after significant operations (e.g., after a physical stock count)
238+
- **Threshold-based**: Append a savepoint when the delta since the last savepoint exceeds a threshold
239+
240+
Since savepoints are domain events, they can also carry business meaning. A `StockCounted` event is not just a technical optimization — it represents a real business activity (a stock count). This makes the savepoint pattern a natural fit for many domains.
241+
242+
> The number of events between savepoints determines the tradeoff: more frequent savepoints mean faster initialization but more events in the stream. Find the right balance for your use case.
243+
{: .prompt-info }

0 commit comments

Comments
 (0)