@kairos-es/feed 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +109 -2
- package/dist/cjs/Feed.js +6 -0
- package/dist/cjs/Feed.js.map +1 -0
- package/dist/cjs/FeedSink.js +54 -0
- package/dist/cjs/FeedSink.js.map +1 -0
- package/dist/cjs/FeedSyncStore.js +10 -0
- package/dist/cjs/FeedSyncStore.js.map +1 -0
- package/dist/cjs/deliver.js +86 -0
- package/dist/cjs/deliver.js.map +1 -0
- package/dist/cjs/errors.js +114 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/identifiers.js +63 -0
- package/dist/cjs/identifiers.js.map +1 -0
- package/dist/cjs/inMemoryFeed.js +328 -0
- package/dist/cjs/inMemoryFeed.js.map +1 -0
- package/dist/cjs/inMemorySink.js +142 -0
- package/dist/cjs/inMemorySink.js.map +1 -0
- package/dist/cjs/index.js +184 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/inspect.js +68 -0
- package/dist/cjs/inspect.js.map +1 -0
- package/dist/cjs/readiness.js +100 -0
- package/dist/cjs/readiness.js.map +1 -0
- package/dist/cjs/reconcile.js +164 -0
- package/dist/cjs/reconcile.js.map +1 -0
- package/dist/cjs/runFeedSync.js +603 -0
- package/dist/cjs/runFeedSync.js.map +1 -0
- package/dist/cjs/tuning.js +60 -0
- package/dist/cjs/tuning.js.map +1 -0
- package/dist/dts-cjs/Feed.d.ts +99 -0
- package/dist/dts-cjs/Feed.d.ts.map +1 -0
- package/dist/dts-cjs/FeedSink.d.ts +189 -0
- package/dist/dts-cjs/FeedSink.d.ts.map +1 -0
- package/dist/dts-cjs/FeedSyncStore.d.ts +281 -0
- package/dist/dts-cjs/FeedSyncStore.d.ts.map +1 -0
- package/dist/dts-cjs/deliver.d.ts +16 -0
- package/dist/dts-cjs/deliver.d.ts.map +1 -0
- package/dist/dts-cjs/errors.d.ts +131 -0
- package/dist/dts-cjs/errors.d.ts.map +1 -0
- package/dist/dts-cjs/identifiers.d.ts +67 -0
- package/dist/dts-cjs/identifiers.d.ts.map +1 -0
- package/dist/dts-cjs/inMemoryFeed.d.ts +32 -0
- package/dist/dts-cjs/inMemoryFeed.d.ts.map +1 -0
- package/dist/dts-cjs/inMemorySink.d.ts +61 -0
- package/dist/dts-cjs/inMemorySink.d.ts.map +1 -0
- package/dist/dts-cjs/index.d.ts +63 -0
- package/dist/dts-cjs/index.d.ts.map +1 -0
- package/dist/dts-cjs/inspect.d.ts +71 -0
- package/dist/dts-cjs/inspect.d.ts.map +1 -0
- package/dist/dts-cjs/package.json +3 -0
- package/dist/dts-cjs/readiness.d.ts +84 -0
- package/dist/dts-cjs/readiness.d.ts.map +1 -0
- package/dist/dts-cjs/reconcile.d.ts +68 -0
- package/dist/dts-cjs/reconcile.d.ts.map +1 -0
- package/dist/dts-cjs/runFeedSync.d.ts +220 -0
- package/dist/dts-cjs/runFeedSync.d.ts.map +1 -0
- package/dist/dts-cjs/tuning.d.ts +40 -0
- package/dist/dts-cjs/tuning.d.ts.map +1 -0
- package/dist/dts-esm/Feed.d.ts +99 -0
- package/dist/dts-esm/Feed.d.ts.map +1 -0
- package/dist/dts-esm/FeedSink.d.ts +189 -0
- package/dist/dts-esm/FeedSink.d.ts.map +1 -0
- package/dist/dts-esm/FeedSyncStore.d.ts +281 -0
- package/dist/dts-esm/FeedSyncStore.d.ts.map +1 -0
- package/dist/dts-esm/deliver.d.ts +16 -0
- package/dist/dts-esm/deliver.d.ts.map +1 -0
- package/dist/dts-esm/errors.d.ts +131 -0
- package/dist/dts-esm/errors.d.ts.map +1 -0
- package/dist/dts-esm/identifiers.d.ts +67 -0
- package/dist/dts-esm/identifiers.d.ts.map +1 -0
- package/dist/dts-esm/inMemoryFeed.d.ts +32 -0
- package/dist/dts-esm/inMemoryFeed.d.ts.map +1 -0
- package/dist/dts-esm/inMemorySink.d.ts +61 -0
- package/dist/dts-esm/inMemorySink.d.ts.map +1 -0
- package/dist/dts-esm/index.d.ts +63 -0
- package/dist/dts-esm/index.d.ts.map +1 -0
- package/dist/dts-esm/inspect.d.ts +71 -0
- package/dist/dts-esm/inspect.d.ts.map +1 -0
- package/dist/dts-esm/package.json +3 -0
- package/dist/dts-esm/readiness.d.ts +84 -0
- package/dist/dts-esm/readiness.d.ts.map +1 -0
- package/dist/dts-esm/reconcile.d.ts +68 -0
- package/dist/dts-esm/reconcile.d.ts.map +1 -0
- package/dist/dts-esm/runFeedSync.d.ts +220 -0
- package/dist/dts-esm/runFeedSync.d.ts.map +1 -0
- package/dist/dts-esm/tuning.d.ts +40 -0
- package/dist/dts-esm/tuning.d.ts.map +1 -0
- package/dist/esm/Feed.js +2 -0
- package/dist/esm/Feed.js.map +1 -0
- package/dist/esm/FeedSink.js +46 -0
- package/dist/esm/FeedSink.js.map +1 -0
- package/dist/esm/FeedSyncStore.js +3 -0
- package/dist/esm/FeedSyncStore.js.map +1 -0
- package/dist/esm/deliver.js +78 -0
- package/dist/esm/deliver.js.map +1 -0
- package/dist/esm/errors.js +98 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/identifiers.js +56 -0
- package/dist/esm/identifiers.js.map +1 -0
- package/dist/esm/inMemoryFeed.js +320 -0
- package/dist/esm/inMemoryFeed.js.map +1 -0
- package/dist/esm/inMemorySink.js +134 -0
- package/dist/esm/inMemorySink.js.map +1 -0
- package/dist/esm/index.js +62 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/inspect.js +58 -0
- package/dist/esm/inspect.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/readiness.js +92 -0
- package/dist/esm/readiness.js.map +1 -0
- package/dist/esm/reconcile.js +153 -0
- package/dist/esm/reconcile.js.map +1 -0
- package/dist/esm/runFeedSync.js +595 -0
- package/dist/esm/runFeedSync.js.map +1 -0
- package/dist/esm/tuning.js +51 -0
- package/dist/esm/tuning.js.map +1 -0
- package/package.json +34 -3
- package/src/Feed.ts +111 -0
- package/src/FeedSink.ts +130 -0
- package/src/FeedSyncStore.ts +326 -0
- package/src/deliver.ts +108 -0
- package/src/errors.ts +134 -0
- package/src/identifiers.ts +92 -0
- package/src/inMemoryFeed.ts +582 -0
- package/src/inMemorySink.ts +215 -0
- package/src/index.ts +136 -0
- package/src/inspect.ts +114 -0
- package/src/readiness.ts +137 -0
- package/src/reconcile.ts +224 -0
- package/src/runFeedSync.ts +882 -0
- package/src/tuning.ts +65 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory sink: a `FeedSink` over a `Ref` of documents, with an optional
|
|
3
|
+
* `FeedResync` and `FeedReconcile`, and the ORACLE the sink law suite holds every
|
|
4
|
+
* other sink against.
|
|
5
|
+
*
|
|
6
|
+
* It keeps the laws by construction: an upsert replaces the whole document, a
|
|
7
|
+
* delete of an absent key is a no-op, so the same batch applied twice leaves it as
|
|
8
|
+
* once; a batch is visible the moment `apply` returns, so `confirm` has nothing to
|
|
9
|
+
* wait for; a resync target is a separate map that `publish` swaps in whole, so a
|
|
10
|
+
* published resync holds exactly what was loaded into it, and a load into a target
|
|
11
|
+
* a later `begin` dropped fails rather than recreating it. Each `begin` names its
|
|
12
|
+
* target by a counter the sink's own state holds, so two workers sharing one sink
|
|
13
|
+
* value — the way two processes share one real index — never share a target, and
|
|
14
|
+
* the name is the same on every run of a test.
|
|
15
|
+
*
|
|
16
|
+
* Its two optional parts are switched on by option rather than always present,
|
|
17
|
+
* because which parts a sink has CHANGES what the driver does — a sink with no
|
|
18
|
+
* `resync` is resynced in place — and a test of either path needs a sink that
|
|
19
|
+
* lacks the other. It also records every batch it is handed, live or into a
|
|
20
|
+
* target, so a case can say how many sends a change cost.
|
|
21
|
+
*
|
|
22
|
+
* It lives for as long as the value is held, independently of any feed, which is
|
|
23
|
+
* what lets a test keep one sink across two in-memory feeds and watch the second
|
|
24
|
+
* feed's fresh generation force a resync.
|
|
25
|
+
*/
|
|
26
|
+
import { Effect, Ref, Stream } from 'effect'
|
|
27
|
+
import type { NonEmptyReadonlyArray } from 'effect/Array'
|
|
28
|
+
import { SinkRetryable } from './errors'
|
|
29
|
+
import type { FeedChange, FeedResync, FeedSink, ResyncTarget } from './FeedSink'
|
|
30
|
+
import type { FeedKey, Generation, SinkName } from './identifiers'
|
|
31
|
+
|
|
32
|
+
/** What the in-memory sink's `apply` and `load` return: where, and which batch. */
|
|
33
|
+
export interface InMemoryReceipt {
|
|
34
|
+
/** `'live'`, or the name of the resync target the batch was loaded into. */
|
|
35
|
+
readonly target: string
|
|
36
|
+
readonly batch: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One batch the sink was handed, and where it went. */
|
|
40
|
+
export interface InMemorySentBatch<D> {
|
|
41
|
+
readonly target: string
|
|
42
|
+
readonly changes: NonEmptyReadonlyArray<FeedChange<D>>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Options for {@link makeInMemorySink}. */
|
|
46
|
+
export interface InMemorySinkOptions {
|
|
47
|
+
readonly name: SinkName
|
|
48
|
+
/** Give the sink a `FeedResync` (an atomic swap). */
|
|
49
|
+
readonly resync?: boolean
|
|
50
|
+
/** Give the sink a `FeedReconcile` (a key listing). */
|
|
51
|
+
readonly reconcile?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The in-memory sink and what a test reads back from it. */
|
|
55
|
+
export interface InMemorySink<D> {
|
|
56
|
+
readonly sink: FeedSink<D, InMemoryReceipt>
|
|
57
|
+
/** The live documents, by key. */
|
|
58
|
+
readonly documents: Effect.Effect<ReadonlyMap<FeedKey, D>>
|
|
59
|
+
/** Every batch handed to `apply` or `load`, in order. */
|
|
60
|
+
readonly batches: Effect.Effect<ReadonlyArray<InMemorySentBatch<D>>>
|
|
61
|
+
/** The generation of every resync published, in order. */
|
|
62
|
+
readonly published: Effect.Effect<ReadonlyArray<Generation>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SinkState<D> {
|
|
66
|
+
readonly live: ReadonlyMap<FeedKey, D>
|
|
67
|
+
readonly targets: ReadonlyMap<string, ReadonlyMap<FeedKey, D>>
|
|
68
|
+
readonly batches: ReadonlyArray<InMemorySentBatch<D>>
|
|
69
|
+
readonly published: ReadonlyArray<Generation>
|
|
70
|
+
/** How many `begin`s this sink has answered: the next target's attempt number. */
|
|
71
|
+
readonly attempts: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LIVE = 'live'
|
|
75
|
+
|
|
76
|
+
const applyTo = <D>(
|
|
77
|
+
documents: ReadonlyMap<FeedKey, D>,
|
|
78
|
+
changes: NonEmptyReadonlyArray<FeedChange<D>>,
|
|
79
|
+
): ReadonlyMap<FeedKey, D> => {
|
|
80
|
+
const next = new Map(documents)
|
|
81
|
+
for (const change of changes) {
|
|
82
|
+
if (change._tag === 'Upsert') {
|
|
83
|
+
next.set(change.key, change.doc)
|
|
84
|
+
} else {
|
|
85
|
+
next.delete(change.key)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return next
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What a load into, or a publish of, a target a later `begin` dropped fails with. */
|
|
92
|
+
const droppedTarget = (name: string): SinkRetryable =>
|
|
93
|
+
new SinkRetryable({
|
|
94
|
+
reason: `the resync target '${name}' was dropped by a later begin`,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/** The resync target's name for one attempt at `generation`; see the module doc. */
|
|
98
|
+
const targetName = (generation: Generation, attempt: number): string =>
|
|
99
|
+
`${LIVE}__g${generation}_${attempt}`
|
|
100
|
+
|
|
101
|
+
/** Build an in-memory sink; see the module doc. */
|
|
102
|
+
export const makeInMemorySink = <D>(
|
|
103
|
+
options: InMemorySinkOptions,
|
|
104
|
+
): Effect.Effect<InMemorySink<D>> =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const state = yield* Ref.make<SinkState<D>>({
|
|
107
|
+
live: new Map(),
|
|
108
|
+
targets: new Map(),
|
|
109
|
+
batches: [],
|
|
110
|
+
published: [],
|
|
111
|
+
attempts: 0,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const record = (
|
|
115
|
+
target: string,
|
|
116
|
+
changes: NonEmptyReadonlyArray<FeedChange<D>>,
|
|
117
|
+
): Effect.Effect<InMemoryReceipt, SinkRetryable> =>
|
|
118
|
+
Effect.flatten(
|
|
119
|
+
Ref.modify(
|
|
120
|
+
state,
|
|
121
|
+
(
|
|
122
|
+
s,
|
|
123
|
+
): [Effect.Effect<InMemoryReceipt, SinkRetryable>, SinkState<D>] => {
|
|
124
|
+
const batches = [...s.batches, { target, changes }]
|
|
125
|
+
const receipt = { target, batch: batches.length }
|
|
126
|
+
if (target === LIVE) {
|
|
127
|
+
return [
|
|
128
|
+
Effect.succeed(receipt),
|
|
129
|
+
{ ...s, batches, live: applyTo(s.live, changes) },
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
const built = s.targets.get(target)
|
|
133
|
+
// Dropped by a later begin: recreating it here is what would let a
|
|
134
|
+
// stale worker's publish swap a fragment in. `FeedResync.load` says so.
|
|
135
|
+
if (built === undefined) {
|
|
136
|
+
return [Effect.fail(droppedTarget(target)), s]
|
|
137
|
+
}
|
|
138
|
+
return [
|
|
139
|
+
Effect.succeed(receipt),
|
|
140
|
+
{
|
|
141
|
+
...s,
|
|
142
|
+
batches,
|
|
143
|
+
targets: new Map(s.targets).set(
|
|
144
|
+
target,
|
|
145
|
+
applyTo(built, changes),
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const resync: FeedResync<D, InMemoryReceipt> = {
|
|
154
|
+
// Every other target is dropped: whatever attempt left it, it will never be
|
|
155
|
+
// published now, and its own publish must fail rather than swap it in.
|
|
156
|
+
begin: (generation) =>
|
|
157
|
+
Ref.modify(state, (s): [ResyncTarget, SinkState<D>] => {
|
|
158
|
+
const attempts = s.attempts + 1
|
|
159
|
+
const name = targetName(generation, attempts)
|
|
160
|
+
return [
|
|
161
|
+
{ generation, name },
|
|
162
|
+
{ ...s, attempts, targets: new Map([[name, new Map()]]) },
|
|
163
|
+
]
|
|
164
|
+
}),
|
|
165
|
+
load: (target, batch) => record(target.name, batch),
|
|
166
|
+
publish: (target) =>
|
|
167
|
+
Effect.flatten(
|
|
168
|
+
Ref.modify(
|
|
169
|
+
state,
|
|
170
|
+
(s): [Effect.Effect<void, SinkRetryable>, SinkState<D>] => {
|
|
171
|
+
const built = s.targets.get(target.name)
|
|
172
|
+
if (built === undefined) {
|
|
173
|
+
return [Effect.fail(droppedTarget(target.name)), s]
|
|
174
|
+
}
|
|
175
|
+
const targets = new Map(s.targets)
|
|
176
|
+
targets.delete(target.name)
|
|
177
|
+
return [
|
|
178
|
+
Effect.void,
|
|
179
|
+
{
|
|
180
|
+
...s,
|
|
181
|
+
live: built,
|
|
182
|
+
targets,
|
|
183
|
+
published: [...s.published, target.generation],
|
|
184
|
+
},
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sink: FeedSink<D, InMemoryReceipt> = {
|
|
192
|
+
name: options.name,
|
|
193
|
+
apply: (batch) => record(LIVE, batch),
|
|
194
|
+
confirm: () => Effect.void,
|
|
195
|
+
...(options.resync === true ? { resync } : {}),
|
|
196
|
+
...(options.reconcile === true
|
|
197
|
+
? {
|
|
198
|
+
reconcile: {
|
|
199
|
+
listKeys: Stream.unwrap(
|
|
200
|
+
Effect.map(Ref.get(state), (s) =>
|
|
201
|
+
Stream.fromIterable(Array.from(s.live.keys())),
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
: {}),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
sink,
|
|
211
|
+
documents: Effect.map(Ref.get(state), (s) => s.live),
|
|
212
|
+
batches: Effect.map(Ref.get(state), (s) => s.batches),
|
|
213
|
+
published: Effect.map(Ref.get(state), (s) => s.published),
|
|
214
|
+
}
|
|
215
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/feed — a state-only change feed: the newest state of each record a
|
|
3
|
+
* read model touches, written inside that read model's own commit, and copied by
|
|
4
|
+
* a sync worker to a sink that has no transaction of its own — a search index, a
|
|
5
|
+
* cache, a mirror (ADR-0019).
|
|
6
|
+
*
|
|
7
|
+
* The feed copies STATE. It never tells anyone each thing that happened; that is
|
|
8
|
+
* `@kairos-es/automation`'s job, and the two are deliberately different shapes: an
|
|
9
|
+
* automation's only progress record is the log, while a feed keeps a cursor, a
|
|
10
|
+
* lease and a parked list beside its rows, because what it mirrors is state and
|
|
11
|
+
* its progress is legitimately a cursor.
|
|
12
|
+
*
|
|
13
|
+
* What is here, and where each part's argument lives:
|
|
14
|
+
*
|
|
15
|
+
* - The identifiers (`identifiers.ts`) and the error vocabulary (`errors.ts`).
|
|
16
|
+
* - The feed's write and read sides, `BatchedFeedWriter` / `FeedWriter` and
|
|
17
|
+
* `FeedSource` (`Feed.ts`): why the feed is written inside the commit, and what
|
|
18
|
+
* "coalesced, latest wins" means for a stored row.
|
|
19
|
+
* - The sink port, `FeedSink` with its optional `FeedResync` and `FeedReconcile`
|
|
20
|
+
* (`FeedSink.ts`): what a sink must keep, and why a sink with neither optional
|
|
21
|
+
* part is refused.
|
|
22
|
+
* - The sync store port, `FeedSyncStore` (`FeedSyncStore.ts`): the cursor, the
|
|
23
|
+
* parked list and the worker lease, and the exact contract a backend implements.
|
|
24
|
+
* - The driver, `runFeedSync` (`runFeedSync.ts`): the lifecycle, the rules it
|
|
25
|
+
* keeps, resync, and its own supervision.
|
|
26
|
+
* - `FeedReadiness` and `materialisationCaughtUp` (`readiness.ts`): why a
|
|
27
|
+
* rebuild's publish can wait for the materialisation, and what it waits on.
|
|
28
|
+
* - `reconcileFeed` (`reconcile.ts`), and `inspectFeed` / `retryParked` /
|
|
29
|
+
* `removeSink` (`inspect.ts`).
|
|
30
|
+
* - The in-memory feed (`inMemoryFeed.ts`) and sink (`inMemorySink.ts`): the parity
|
|
31
|
+
* oracles, and what the in-memory feed refuses.
|
|
32
|
+
*
|
|
33
|
+
* Wiring, end to end, in memory:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const feed = yield* makeInMemoryFeed({ feed: FeedName.make('course-search'), doc: CourseDoc })
|
|
37
|
+
* const store = yield* withInMemoryFeed(yield* makeInMemoryProjectionStore, feed, key)
|
|
38
|
+
*
|
|
39
|
+
* // In the read model's `apply`, as the LAST step of the batch:
|
|
40
|
+
* const apply = (batch) =>
|
|
41
|
+
* feed.write(Arr.lastNonEmpty(batch).sequenced.position, (writer) =>
|
|
42
|
+
* Effect.forEach(touchedCourses(batch), (course) =>
|
|
43
|
+
* writer.upsert(FeedKey.make(course.id), toDoc(course)),
|
|
44
|
+
* ),
|
|
45
|
+
* )
|
|
46
|
+
*
|
|
47
|
+
* // Beside the projections, in a fibre of its own:
|
|
48
|
+
* const { sink } = yield* makeInMemorySink<CourseDoc>({ name: SinkName.make('search'), resync: true })
|
|
49
|
+
* yield* Effect.forkScoped(runFeedSync({ source: feed.source, sink, store: feed.syncStore }))
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
CursorSuperseded,
|
|
55
|
+
FeedSyncStalled,
|
|
56
|
+
FeedUnreadable,
|
|
57
|
+
FeedWriteFailed,
|
|
58
|
+
LeaseHeld,
|
|
59
|
+
LeaseLost,
|
|
60
|
+
SinkHalted,
|
|
61
|
+
SinkRejected,
|
|
62
|
+
SinkRetryable,
|
|
63
|
+
} from './errors'
|
|
64
|
+
export type {
|
|
65
|
+
BatchedFeedWriter,
|
|
66
|
+
FeedRow,
|
|
67
|
+
FeedSource,
|
|
68
|
+
FeedWriter,
|
|
69
|
+
} from './Feed'
|
|
70
|
+
export {
|
|
71
|
+
changeOf,
|
|
72
|
+
FeedChange,
|
|
73
|
+
type FeedChangeDefinition,
|
|
74
|
+
type FeedReconcile,
|
|
75
|
+
type FeedResync,
|
|
76
|
+
type FeedSink,
|
|
77
|
+
type ResyncTarget,
|
|
78
|
+
} from './FeedSink'
|
|
79
|
+
export {
|
|
80
|
+
type FeedCursor,
|
|
81
|
+
type FeedLease,
|
|
82
|
+
type FeedSyncDescription,
|
|
83
|
+
type FeedSyncStore,
|
|
84
|
+
type LeaseDescription,
|
|
85
|
+
type ParkedChange,
|
|
86
|
+
type ParkedRecord,
|
|
87
|
+
type SinkSyncState,
|
|
88
|
+
sameCursor,
|
|
89
|
+
} from './FeedSyncStore'
|
|
90
|
+
export {
|
|
91
|
+
FEED_ORIGIN,
|
|
92
|
+
FeedKey,
|
|
93
|
+
FeedName,
|
|
94
|
+
FeedSeq,
|
|
95
|
+
type FeedSinkId,
|
|
96
|
+
Generation,
|
|
97
|
+
LeaseHolder,
|
|
98
|
+
SinkName,
|
|
99
|
+
} from './identifiers'
|
|
100
|
+
export {
|
|
101
|
+
type InMemoryFeed,
|
|
102
|
+
type InMemoryFeedOptions,
|
|
103
|
+
makeInMemoryFeed,
|
|
104
|
+
withInMemoryFeed,
|
|
105
|
+
} from './inMemoryFeed'
|
|
106
|
+
export {
|
|
107
|
+
type InMemoryReceipt,
|
|
108
|
+
type InMemorySentBatch,
|
|
109
|
+
type InMemorySink,
|
|
110
|
+
type InMemorySinkOptions,
|
|
111
|
+
makeInMemorySink,
|
|
112
|
+
} from './inMemorySink'
|
|
113
|
+
export {
|
|
114
|
+
type FeedInspection,
|
|
115
|
+
inspectFeed,
|
|
116
|
+
removeSink,
|
|
117
|
+
retryParked,
|
|
118
|
+
type SinkInspection,
|
|
119
|
+
} from './inspect'
|
|
120
|
+
export {
|
|
121
|
+
type CaughtUpReadModel,
|
|
122
|
+
type FeedReadiness,
|
|
123
|
+
type FeedReadinessProbe,
|
|
124
|
+
materialisationCaughtUp,
|
|
125
|
+
} from './readiness'
|
|
126
|
+
export {
|
|
127
|
+
type ReconcileFeedOptions,
|
|
128
|
+
type ReconcileReport,
|
|
129
|
+
reconcileFeed,
|
|
130
|
+
} from './reconcile'
|
|
131
|
+
export {
|
|
132
|
+
type FeedSyncRestart,
|
|
133
|
+
type FeedSyncTuning,
|
|
134
|
+
type RunFeedSyncOptions,
|
|
135
|
+
runFeedSync,
|
|
136
|
+
} from './runFeedSync'
|
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `inspectFeed`, `retryParked` and `removeSink`: an operator's view of a feed's
|
|
3
|
+
* sinks, and the two repairs an operator makes by hand.
|
|
4
|
+
*
|
|
5
|
+
* None of them needs the sink's client, because everything they read or change
|
|
6
|
+
* lives in the source database beside the feed: an operator can see a sink's lag,
|
|
7
|
+
* its lease, its parked records and when it last advanced, send a parked record
|
|
8
|
+
* again, and retire a sink, without the credentials of the index concerned. The
|
|
9
|
+
* same choice `@kairos-es/automation` made for its own `inspect`. There is no CLI
|
|
10
|
+
* or UI over any of them; they are effects an application wires into whatever it
|
|
11
|
+
* already has.
|
|
12
|
+
*
|
|
13
|
+
* `lastAdvance` and `removeSink` are a pair. Every sink registered on a feed holds
|
|
14
|
+
* its tombstone collection back, so a sink the application stopped running and
|
|
15
|
+
* never removed lets tombstones pile up for good; `lastAdvance` is how such a sink
|
|
16
|
+
* shows — still, while the head moves — and `removeSink` is what lets collection
|
|
17
|
+
* proceed without it. `FeedSyncStore.ts` owns both rules.
|
|
18
|
+
*/
|
|
19
|
+
import { type DateTime, Effect, Option } from 'effect'
|
|
20
|
+
import type { FeedUnreadable, FeedWriteFailed, LeaseHeld } from './errors'
|
|
21
|
+
import type { FeedSource } from './Feed'
|
|
22
|
+
import type {
|
|
23
|
+
FeedCursor,
|
|
24
|
+
FeedSyncStore,
|
|
25
|
+
LeaseDescription,
|
|
26
|
+
ParkedRecord,
|
|
27
|
+
} from './FeedSyncStore'
|
|
28
|
+
import type {
|
|
29
|
+
FeedKey,
|
|
30
|
+
FeedName,
|
|
31
|
+
FeedSeq,
|
|
32
|
+
Generation,
|
|
33
|
+
SinkName,
|
|
34
|
+
} from './identifiers'
|
|
35
|
+
|
|
36
|
+
/** One sink of a feed, as `inspectFeed` reports it. */
|
|
37
|
+
export interface SinkInspection {
|
|
38
|
+
readonly sink: SinkName
|
|
39
|
+
/** The generation the sink's cursor was saved at; `None` before its first sync. */
|
|
40
|
+
readonly generation: Option.Option<Generation>
|
|
41
|
+
readonly cursor: Option.Option<FeedCursor>
|
|
42
|
+
/** When the cursor last moved; `None` if it never has. See `SinkSyncState.lastAdvance`. */
|
|
43
|
+
readonly lastAdvance: Option.Option<DateTime.Utc>
|
|
44
|
+
/** True when the next pass will resync: no cursor, or one at another generation. */
|
|
45
|
+
readonly resyncDue: boolean
|
|
46
|
+
/** Rows the sink has still to receive; see `SinkSyncState.pending`. */
|
|
47
|
+
readonly lag: number
|
|
48
|
+
readonly lease: Option.Option<LeaseDescription>
|
|
49
|
+
readonly parked: ReadonlyArray<ParkedRecord>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A feed and its sinks. */
|
|
53
|
+
export interface FeedInspection {
|
|
54
|
+
readonly feed: FeedName
|
|
55
|
+
readonly generation: Generation
|
|
56
|
+
/** The highest `seq` the feed holds. */
|
|
57
|
+
readonly head: FeedSeq
|
|
58
|
+
readonly sinks: ReadonlyArray<SinkInspection>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every sink of `source`'s feed: its cursor, lag, lease and parked records. */
|
|
62
|
+
export const inspectFeed = <D>(
|
|
63
|
+
source: FeedSource<D>,
|
|
64
|
+
store: FeedSyncStore,
|
|
65
|
+
): Effect.Effect<FeedInspection, FeedUnreadable> =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const generation = yield* source.generation
|
|
68
|
+
const description = yield* store.describe(source.feed)
|
|
69
|
+
return {
|
|
70
|
+
feed: source.feed,
|
|
71
|
+
generation,
|
|
72
|
+
head: description.head,
|
|
73
|
+
sinks: description.sinks.map((state) => ({
|
|
74
|
+
sink: state.sink,
|
|
75
|
+
generation: Option.map(state.cursor, (c) => c.generation),
|
|
76
|
+
cursor: state.cursor,
|
|
77
|
+
lastAdvance: state.lastAdvance,
|
|
78
|
+
resyncDue: Option.match(state.cursor, {
|
|
79
|
+
onNone: () => true,
|
|
80
|
+
onSome: (c) => c.generation !== generation,
|
|
81
|
+
}),
|
|
82
|
+
lag: state.pending,
|
|
83
|
+
lease: state.lease,
|
|
84
|
+
parked: state.parked,
|
|
85
|
+
})),
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Send one parked record again: delete its park and re-stamp its row's `seq`, so
|
|
91
|
+
* the next pass of every sink on the feed sends it. Succeeds with whether the key
|
|
92
|
+
* was parked at all. Every sink on the feed receives the record again, not only
|
|
93
|
+
* `sink`; an idempotent sink absorbs that.
|
|
94
|
+
*/
|
|
95
|
+
export const retryParked = (
|
|
96
|
+
store: FeedSyncStore,
|
|
97
|
+
args: {
|
|
98
|
+
readonly feed: FeedName
|
|
99
|
+
readonly sink: SinkName
|
|
100
|
+
readonly key: FeedKey
|
|
101
|
+
},
|
|
102
|
+
): Effect.Effect<boolean, FeedWriteFailed> => store.retryParked(args)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Retire `sink` from `feed`: delete its cursor, its lease record and its parked
|
|
106
|
+
* records, so it no longer holds the feed's tombstone collection back. Succeeds
|
|
107
|
+
* with whether the store held anything for it. Fails `LeaseHeld`, removing
|
|
108
|
+
* nothing, while a worker for the sink is running: stop the worker first. A worker
|
|
109
|
+
* started for the sink later registers it again from scratch, and resyncs.
|
|
110
|
+
*/
|
|
111
|
+
export const removeSink = (
|
|
112
|
+
store: FeedSyncStore,
|
|
113
|
+
args: { readonly feed: FeedName; readonly sink: SinkName },
|
|
114
|
+
): Effect.Effect<boolean, LeaseHeld | FeedWriteFailed> => store.removeSink(args)
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FeedReadiness` — when a rebuilt copy may go live — and
|
|
3
|
+
* `materialisationCaughtUp`, the readiness of the common case.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a rebuild has anything to wait for
|
|
6
|
+
*
|
|
7
|
+
* A reset empties the feed and moves its generation on, and the materialisation
|
|
8
|
+
* then replays the log, writing the feed afresh as it goes. The sync worker sees
|
|
9
|
+
* the new generation within a poll interval and starts its resync at once, but the
|
|
10
|
+
* feed it pages through is only as complete as the replay is far along. Reaching
|
|
11
|
+
* the feed's last row says nothing about the replay: it is wherever the replay had
|
|
12
|
+
* got to. So a resync that publishes at the feed's end swaps a complete live copy
|
|
13
|
+
* for whatever fraction of the rebuild the replay had written, and search serves
|
|
14
|
+
* that partial copy until the tail fills the rest in.
|
|
15
|
+
*
|
|
16
|
+
* Only the materialisation knows how far its replay has got, and the feed's ports
|
|
17
|
+
* deliberately name no materialisation, so the driver is TOLD, through an optional
|
|
18
|
+
* readiness signal on `runFeedSync`, and holds the step that makes the rebuild
|
|
19
|
+
* final until the signal says yes. With no signal the resync publishes at the
|
|
20
|
+
* feed's end, as it always did, and a rebuild can serve a partial copy for as long
|
|
21
|
+
* as the replay takes.
|
|
22
|
+
*
|
|
23
|
+
* ## Why it is a function returning an effect
|
|
24
|
+
*
|
|
25
|
+
* "Caught up" has to be relative to a fixed point: the log as it stood when the
|
|
26
|
+
* rebuild began. Judged against the log as it stands at each ask, a read model
|
|
27
|
+
* under steady writes is always a batch or two behind, and the publish would wait
|
|
28
|
+
* for a quiet moment that may never come. So the signal is asked ONCE per resync
|
|
29
|
+
* attempt, as the attempt starts, for the generation that attempt rebuilds, and
|
|
30
|
+
* returns the PROBE the worker then polls — by which time whatever point the probe
|
|
31
|
+
* judges by is fixed. An attempt starts after the reset that moved the generation,
|
|
32
|
+
* so a point taken then is at or after the log as it stood at the reset: the error
|
|
33
|
+
* is towards waiting slightly longer, never towards publishing early.
|
|
34
|
+
*
|
|
35
|
+
* Both halves fail `FeedUnreadable`, the feed's own "a read could not be made":
|
|
36
|
+
* the worker cannot tell a probe that could not read the materialisation's
|
|
37
|
+
* progress from a feed it could not read, and needs to do the same thing about
|
|
38
|
+
* either — end the session and try again after a back-off.
|
|
39
|
+
*/
|
|
40
|
+
import {
|
|
41
|
+
type AnyProjection,
|
|
42
|
+
composeProjections,
|
|
43
|
+
DcbEventStore,
|
|
44
|
+
DEFAULT_CATCH_UP_PAGE_SIZE,
|
|
45
|
+
type Position,
|
|
46
|
+
ReadLimit,
|
|
47
|
+
} from '@kairos-es/core'
|
|
48
|
+
import type { KeyedProjectionStore } from '@kairos-es/read'
|
|
49
|
+
import { Array as Arr, Effect, Option, Stream } from 'effect'
|
|
50
|
+
import { FeedUnreadable } from './errors'
|
|
51
|
+
import type { Generation } from './identifiers'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Polled by the worker, between pages, until it answers `true`: the rebuild this
|
|
55
|
+
* probe was made for may now go live. See the module doc.
|
|
56
|
+
*/
|
|
57
|
+
export type FeedReadinessProbe = Effect.Effect<boolean, FeedUnreadable>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Asked once per resync attempt, as it starts, for the generation it rebuilds,
|
|
61
|
+
* and answering with the {@link FeedReadinessProbe} that attempt polls. See the
|
|
62
|
+
* module doc for why it is two steps.
|
|
63
|
+
*/
|
|
64
|
+
export type FeedReadiness = (
|
|
65
|
+
generation: Generation,
|
|
66
|
+
) => Effect.Effect<FeedReadinessProbe, FeedUnreadable>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What {@link materialisationCaughtUp} needs of a read model: its bound view store
|
|
70
|
+
* and its slices. Structural, so any `ReadModel` from `@kairos-es/read` is one.
|
|
71
|
+
*/
|
|
72
|
+
export interface CaughtUpReadModel {
|
|
73
|
+
readonly store: KeyedProjectionStore
|
|
74
|
+
readonly slices: Readonly<Record<string, AnyProjection>>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The readiness of the common case: the materialisation's checkpoint has reached
|
|
79
|
+
* the last event its query selects, as the log stood when the resync attempt
|
|
80
|
+
* started. Pass the very read model whose `apply` writes the feed, so its query
|
|
81
|
+
* and its checkpoint are the ones the replay moves.
|
|
82
|
+
*
|
|
83
|
+
* The point is the last event the read model's QUERY selects, and not the log's
|
|
84
|
+
* head, because the runner advances its checkpoint to the positions it is
|
|
85
|
+
* delivered and is never delivered an event its query does not select: a
|
|
86
|
+
* checkpoint judged against a head an unrelated event moved would never arrive.
|
|
87
|
+
* The store hands no cheaper way to that position than reading up to it, so each
|
|
88
|
+
* attempt starts with one paged read of the query's events past the checkpoint —
|
|
89
|
+
* the backlog the replay itself is about to read, a page at a time and never held
|
|
90
|
+
* whole — and each probe after it is one checkpoint read.
|
|
91
|
+
*
|
|
92
|
+
* Generation-blind, deliberately: the read model has one checkpoint whatever the
|
|
93
|
+
* feed's generation, and an attempt whose generation moves on while it waits is
|
|
94
|
+
* abandoned by the worker's own generation check, so the probe never has to know.
|
|
95
|
+
*/
|
|
96
|
+
export const materialisationCaughtUp = (
|
|
97
|
+
readModel: CaughtUpReadModel,
|
|
98
|
+
): Effect.Effect<FeedReadiness, never, DcbEventStore> =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const log = yield* DcbEventStore
|
|
101
|
+
const query = composeProjections(readModel.slices).query
|
|
102
|
+
const limit = ReadLimit.make(DEFAULT_CATCH_UP_PAGE_SIZE)
|
|
103
|
+
const checkpoint = Effect.mapError(
|
|
104
|
+
readModel.store.readCheckpoint,
|
|
105
|
+
(cause) =>
|
|
106
|
+
new FeedUnreadable({
|
|
107
|
+
reason: `the checkpoint of projection '${readModel.store.key.projection}' could not be read`,
|
|
108
|
+
cause,
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// The last position the query selects after `from`, or `from` itself when it
|
|
113
|
+
// selects none: page by page, so the backlog is never held in memory at once.
|
|
114
|
+
const lastSelectedAfter = (from: Position): Effect.Effect<Position> =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
let last = from
|
|
117
|
+
for (;;) {
|
|
118
|
+
const response = yield* log.read(query, { after: last, limit })
|
|
119
|
+
const page = yield* Stream.runCollect(response.events)
|
|
120
|
+
const final = Arr.last(page)
|
|
121
|
+
if (Option.isSome(final)) {
|
|
122
|
+
last = final.value.position
|
|
123
|
+
}
|
|
124
|
+
if (page.length < limit) {
|
|
125
|
+
return last
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const readiness: FeedReadiness = () =>
|
|
131
|
+
Effect.map(
|
|
132
|
+
Effect.flatMap(checkpoint, lastSelectedAfter),
|
|
133
|
+
(target): FeedReadinessProbe =>
|
|
134
|
+
Effect.map(checkpoint, (reached) => reached >= target),
|
|
135
|
+
)
|
|
136
|
+
return readiness
|
|
137
|
+
})
|