Skip to content

Commit c65a764

Browse files
authored
Implement event store caching (#27)
2 parents 98bc159 + 9a14086 commit c65a764

8 files changed

Lines changed: 331 additions & 17 deletions

File tree

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"nodemailer": "^7.0.5",
2222
"pg": "^8.8.0",
2323
"reflect-metadata": "^0.2.2",
24+
"sorted-btree": "^1.8.1",
2425
"tsyringe": "^4.8.0",
2526
"winston": "^3.8.2",
2627
"zod": "^3.24.1"

src/app/eventStore.ts

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,83 @@ import { PostgresTransaction } from '@/lib/postgres';
1919
import { log } from '@/common/util/Logger';
2020
import { POSIX } from '@/lib/time';
2121
import { Future } from '@/lib/Future';
22+
import { TreeMap } from '@/lib/TreeMap';
23+
import { Nullable } from '@/lib/Maybe';
2224

2325
type WithEventStore = <E, T>(
2426
onError: (e: Error) => E,
2527
f: (s: EventStore) => Future<E, T>,
2628
) => Future<E, T>;
2729

30+
type LoadedAggregate<T extends Aggregate<T>> = {
31+
aggregate: T;
32+
lastEvent: EventInfo;
33+
};
34+
2835
class PostgresEventStore implements EventStore {
36+
// This cache allows us to efficiently call `find` and `try_find` multiple
37+
// times within a transaction. This makes reactions and commands simpler as
38+
// there is no need to manually apply to the aggregate the transformations
39+
// performed by newly emitted events in those functions. Instead we can just
40+
// call `find` again and load the latest version of the aggregate for free.
41+
private cache: TreeMap<Id<Aggregate<unknown>>, LoadedAggregate<any>>;
42+
43+
// An instance of this class never lives loger than the transaction
44+
// it is associated with.
2945
constructor(
3046
private transaction: PostgresTransaction,
3147
private readonly schemas: Schemas,
3248
private readonly eventStoreTable: string,
33-
) {}
49+
) {
50+
this.cache = TreeMap.new_();
51+
}
3452

3553
async find<T extends Aggregate<T>>(
3654
cls: Constructor<T>,
3755
aggregateId: Id<T>,
38-
): Promise<{ aggregate: T; lastEvent: EventInfo }> {
56+
): Promise<T> {
57+
return (await this._find(cls, aggregateId)).aggregate;
58+
}
59+
60+
async try_find<T extends Aggregate<T>>(
61+
cls: Constructor<T>,
62+
aggregateId: Id<T>,
63+
): Promise<T | null> {
64+
const found = await this._try_find(cls, aggregateId);
65+
return found ? found.aggregate : null;
66+
}
67+
68+
private async _find<T extends Aggregate<T>>(
69+
cls: Constructor<T>,
70+
aggregateId: Id<T>,
71+
): Promise<LoadedAggregate<T>> {
72+
const found = await this._try_find(cls, aggregateId);
73+
74+
if (found == null) {
75+
throw new Error(`Unknown aggregate ID ${aggregateId.value}`);
76+
}
77+
78+
return found;
79+
}
80+
81+
private async _try_find<T extends Aggregate<T>>(
82+
cls: Constructor<T>,
83+
aggregateId: Id<T>,
84+
): Promise<Nullable<LoadedAggregate<T>>> {
85+
const found = this.cache_load(aggregateId);
86+
if (found !== null) {
87+
return found;
88+
}
89+
3990
const events = await this.findAll(aggregateId);
40-
const { lastEvent, aggregate } = this.schemas
41-
.hydrate(cls, events)
42-
.unwrap((e) => e);
4391

44-
return { aggregate, lastEvent };
92+
if (events.length === 0) {
93+
return null;
94+
}
95+
96+
const loaded = this.schemas.hydrate(cls, events).unwrap((e) => e);
97+
this.cache_save(loaded);
98+
return loaded;
4599
}
46100

47101
async emit<T extends Aggregate<T>>(args: {
@@ -50,13 +104,14 @@ class PostgresEventStore implements EventStore {
50104
event_id?: Id<Event<T>>;
51105
correlation_id?: Id<Event<T>>;
52106
causation_id?: Id<Event<T>>;
53-
}): Promise<void> {
107+
}): Promise<{ event: Event<T>; info: EventInfo }> {
54108
const event = args.event;
55109
const event_id = args.event_id || Id.random();
56110
let info: EventInfo;
111+
let aggregate: T;
57112
switch (true) {
58113
case event instanceof CreationEvent: {
59-
const aggregate: T = event.createAggregate();
114+
aggregate = event.createAggregate();
60115
info = {
61116
event_id,
62117
aggregate_id: aggregate.aggregateId,
@@ -68,16 +123,17 @@ class PostgresEventStore implements EventStore {
68123
break;
69124
}
70125
case event instanceof TransformationEvent: {
71-
const { aggregate, lastEvent } = await this.find(
126+
const found = await this._find(
72127
args.aggregate,
73128
event.values.aggregateId,
74129
);
130+
aggregate = found.aggregate;
75131
info = {
76132
event_id,
77133
aggregate_id: aggregate.aggregateId,
78134
aggregate_version: aggregate.aggregateVersion + 1,
79-
correlation_id: lastEvent.correlation_id,
80-
causation_id: lastEvent.causation_id,
135+
correlation_id: found.lastEvent.correlation_id,
136+
causation_id: found.lastEvent.causation_id,
81137
recorded_on: POSIX.now(),
82138
};
83139
break;
@@ -87,6 +143,8 @@ class PostgresEventStore implements EventStore {
87143
}
88144

89145
await this.insert<Event<T>>({ info, event });
146+
this.cache_save({ aggregate, lastEvent: info });
147+
return { event, info };
90148
}
91149

92150
async doesEventAlreadyExist(eventId: Id<Event<any>>): Promise<boolean> {
@@ -157,6 +215,16 @@ class PostgresEventStore implements EventStore {
157215
throw new Error(`Failed to save event: ${edata.info.event_id}: ${error}`);
158216
}
159217
}
218+
219+
private cache_save<T extends Aggregate<T>>(loaded: LoadedAggregate<T>): void {
220+
this.cache.set(loaded.aggregate.aggregateId, loaded);
221+
}
222+
223+
private cache_load<T extends Aggregate<T>>(
224+
id: Id<T>,
225+
): Nullable<LoadedAggregate<T>> {
226+
return this.cache.get(id).asNullable();
227+
}
160228
}
161229

162230
// Prepare the database to be used as an event store.

src/domain/cookingClub/membership/reaction/evaluateApplication.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ const handler: ReactionHandler<Events> = ({
1919
store,
2020
}): Future<AmbarResponse, void> =>
2121
Future.attemptP<void>(async () => {
22-
const { aggregate: membership } = await store.find(
23-
Membership,
24-
event.values.aggregateId,
25-
);
22+
const membership = await store.find(Membership, event.values.aggregateId);
2623

2724
if (membership.status !== 'Requested') {
2825
return;

src/lib/TreeMap.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
A Map type that requires a comparison function
3+
*/
4+
export { TreeMap, stringMap };
5+
6+
import BTree from 'sorted-btree';
7+
8+
import { Maybe, Just, Nothing } from '@/lib/Maybe';
9+
10+
const stringMap = <T>(): TreeMap<string, T> =>
11+
TreeMap.new((x: string, y: string) => (x > y ? 1 : x < y ? -1 : 0));
12+
13+
interface Comparable<T> {
14+
compare(other: T): number;
15+
}
16+
17+
// This is just a wrapper around BTree which requires
18+
// the comparison function.
19+
class TreeMap<K, V> {
20+
// @ts-expect-error: unused _. Prevent instantiation by casting.
21+
private readonly _: null = null;
22+
tree: BTree<K, V>;
23+
compare: (l: K, r: K) => number;
24+
25+
static new<K, V>(compare: (l: K, r: K) => number): TreeMap<K, V> {
26+
return new TreeMap(compare);
27+
}
28+
29+
static new_<K extends Comparable<K>, V>(): TreeMap<K, V> {
30+
const compare = (x: K, y: K) => x.compare(y);
31+
return new TreeMap(compare);
32+
}
33+
34+
static from<K, V>(
35+
compare: (l: K, r: K) => number,
36+
xs: Array<[K, V]>,
37+
): TreeMap<K, V> {
38+
return TreeMap.new<K, V>(compare).setEntries(xs[Symbol.iterator]());
39+
}
40+
41+
static from_<K extends Comparable<K>, V>(xs: Array<[K, V]>): TreeMap<K, V> {
42+
return TreeMap.new_<K, V>().setEntries(xs[Symbol.iterator]());
43+
}
44+
45+
private constructor(compare: (l: K, r: K) => number) {
46+
this.compare = compare;
47+
this.tree = new BTree([], compare);
48+
}
49+
50+
set(k: K, v: V) {
51+
this.tree.set(k, v);
52+
return this;
53+
}
54+
55+
setWith(k: K, v: V, f: (old: V, _new: V) => V) {
56+
const found = this.tree.get(k);
57+
if (found !== undefined) {
58+
this.tree.set(k, f(found, v));
59+
} else {
60+
this.tree.set(k, v);
61+
}
62+
return this;
63+
}
64+
65+
get(k: K): Maybe<V> {
66+
const found = this.tree.get(k);
67+
return found !== undefined ? Just(found) : Nothing();
68+
}
69+
70+
has(k: K): boolean {
71+
return this.tree.has(k);
72+
}
73+
74+
remove(k: K): TreeMap<K, V> {
75+
this.tree.delete(k);
76+
return this;
77+
}
78+
79+
keys(): IterableIterator<K> {
80+
return this.tree.keys();
81+
}
82+
83+
values(): IterableIterator<V> {
84+
return this.tree.values();
85+
}
86+
87+
entries(): IterableIterator<[K, V]> {
88+
return this.tree.entries();
89+
}
90+
91+
setEntries(it: IterableIterator<[K, V]>) {
92+
for (const [k, v] of it) {
93+
this.set(k, v);
94+
}
95+
return this;
96+
}
97+
98+
union(other: TreeMap<K, V>) {
99+
for (const [k, v] of other.entries()) {
100+
this.set(k, v);
101+
}
102+
return this;
103+
}
104+
105+
unionWith(other: TreeMap<K, V>, f: (old: V, new_: V) => V) {
106+
for (const [k, v] of other.entries()) {
107+
this.setWith(k, v, f);
108+
}
109+
return this;
110+
}
111+
112+
// Create a new TreeMap from keys common to two other maps.
113+
intersectionWith<W, X>(
114+
other: TreeMap<K, W>,
115+
f: (left: V, right: W) => X,
116+
): TreeMap<K, X> {
117+
const result = new TreeMap<K, X>(this.compare);
118+
for (const [k, v] of this.entries()) {
119+
const found = other.get(k);
120+
if (found instanceof Just) {
121+
result.set(k, f(v, found.value));
122+
}
123+
}
124+
return result;
125+
}
126+
127+
// Difference in the set of keys
128+
// A.difference(B) equals A minus all keys present in B.
129+
difference(other: TreeMap<K, unknown>): TreeMap<K, V> {
130+
const diff = new TreeMap<K, V>(this.compare);
131+
for (const [k, v] of this.entries()) {
132+
if (!other.has(k)) {
133+
diff.set(k, v);
134+
}
135+
}
136+
return diff;
137+
}
138+
139+
mapWithKeys<W>(f: (k: K, v: V) => W): TreeMap<K, W> {
140+
const n = new TreeMap<K, W>(this.compare);
141+
for (const [k, v] of this.entries()) {
142+
n.set(k, f(k, v));
143+
}
144+
return n;
145+
}
146+
147+
map<W>(f: (v: V) => W): TreeMap<K, W> {
148+
return this.mapWithKeys((_, v) => f(v));
149+
}
150+
151+
size(): number {
152+
return this.tree.size;
153+
}
154+
}

0 commit comments

Comments
 (0)