@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
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile: delete what a sink holds that the feed no longer has.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a feed needs it at all
|
|
5
|
+
*
|
|
6
|
+
* The steady state deletes through tombstones, and a tombstone lives only until
|
|
7
|
+
* every sink registered on the feed has passed it. A record can still be left
|
|
8
|
+
* behind in a sink: a sink restored from a backup, a key written into the index by
|
|
9
|
+
* hand, or a record deleted before a reset, whose tombstone the reset cleared with
|
|
10
|
+
* every other row, followed by an in-place resync that, having no atomic swap,
|
|
11
|
+
* sends only the rows the new generation holds and so never deletes it. Reconcile is the one operation that compares the sink with the feed
|
|
12
|
+
* rather than replaying the feed into the sink, and so the only one that finds
|
|
13
|
+
* those orphans.
|
|
14
|
+
*
|
|
15
|
+
* ## The order of the two reads, and why it is safe
|
|
16
|
+
*
|
|
17
|
+
* The sink's keys are listed FIRST and the feed's live keys read SECOND. A key the
|
|
18
|
+
* sink lists that has a live row by the time the feed is read is kept, so a record
|
|
19
|
+
* written while reconcile runs is never deleted by it. A key whose row turns into
|
|
20
|
+
* a tombstone after the feed is read is kept too, and its tombstone deletes it on
|
|
21
|
+
* the next pass. And a key whose row comes back to life after the feed is read is
|
|
22
|
+
* deleted here and re-sent by the next pass, because its new `seq` is above the
|
|
23
|
+
* cursor. Reconcile runs inside the worker that holds the lease, between batches,
|
|
24
|
+
* so nothing else is sending to the sink while it does (ADR-0019 §8).
|
|
25
|
+
*
|
|
26
|
+
* ## What it costs
|
|
27
|
+
*
|
|
28
|
+
* It holds every key the sink lists and every live key of the feed in memory at
|
|
29
|
+
* once, and reads every live document to find the keys. It is a periodic repair,
|
|
30
|
+
* not a steady-state path: `reconcileEvery` should be measured in hours.
|
|
31
|
+
*/
|
|
32
|
+
import { Array as Arr, Effect, Option, Stream } from 'effect'
|
|
33
|
+
import type { NonEmptyReadonlyArray } from 'effect/Array'
|
|
34
|
+
import { deliver, type Pending } from './deliver'
|
|
35
|
+
import type {
|
|
36
|
+
FeedUnreadable,
|
|
37
|
+
FeedWriteFailed,
|
|
38
|
+
LeaseHeld,
|
|
39
|
+
LeaseLost,
|
|
40
|
+
SinkHalted,
|
|
41
|
+
SinkRetryable,
|
|
42
|
+
} from './errors'
|
|
43
|
+
import type { FeedSource } from './Feed'
|
|
44
|
+
import { FeedChange, type FeedReconcile, type FeedSink } from './FeedSink'
|
|
45
|
+
import type { FeedLease, FeedSyncStore } from './FeedSyncStore'
|
|
46
|
+
import { FEED_ORIGIN, type FeedKey, type FeedSinkId } from './identifiers'
|
|
47
|
+
import { DEFAULT_FEED_PAGE_SIZE, readLimit } from './tuning'
|
|
48
|
+
|
|
49
|
+
/** What one reconcile did. */
|
|
50
|
+
export interface ReconcileReport {
|
|
51
|
+
/** How many keys the sink listed. */
|
|
52
|
+
readonly listed: number
|
|
53
|
+
/** The orphans deleted: listed by the sink, with no live row in the feed. */
|
|
54
|
+
readonly deleted: ReadonlyArray<FeedKey>
|
|
55
|
+
/** Orphans whose delete the sink refused, with its reason. They stay in the sink. */
|
|
56
|
+
readonly refused: ReadonlyArray<{
|
|
57
|
+
readonly key: FeedKey
|
|
58
|
+
readonly reason: string
|
|
59
|
+
}>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every key of `source` whose row is live, read page by page from the origin. */
|
|
63
|
+
export const liveKeys = <D>(
|
|
64
|
+
source: FeedSource<D>,
|
|
65
|
+
pageSize: number,
|
|
66
|
+
): Effect.Effect<ReadonlySet<FeedKey>, FeedUnreadable> =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const live = new Set<FeedKey>()
|
|
69
|
+
const limit = readLimit(pageSize)
|
|
70
|
+
let after = FEED_ORIGIN
|
|
71
|
+
for (;;) {
|
|
72
|
+
const rows = yield* source.changesAfter(after, limit)
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
if (Option.isSome(row.doc)) {
|
|
75
|
+
live.add(row.key)
|
|
76
|
+
} else {
|
|
77
|
+
live.delete(row.key)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const last = rows.at(-1)
|
|
81
|
+
if (last === undefined || rows.length < pageSize) {
|
|
82
|
+
return live
|
|
83
|
+
}
|
|
84
|
+
after = last.seq
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What `reconcileWith` hands its caller: the report, and which keys are now gone
|
|
90
|
+
* from the sink for good — not in the feed's live rows, and either deleted by this
|
|
91
|
+
* reconcile or not listed by the sink at all. Package-internal: the report is the
|
|
92
|
+
* consumer's, the predicate is for {@link unparkGone}.
|
|
93
|
+
*
|
|
94
|
+
* @internal
|
|
95
|
+
*/
|
|
96
|
+
export interface Reconciled {
|
|
97
|
+
readonly report: ReconcileReport
|
|
98
|
+
readonly isGone: (key: FeedKey) => boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reconcile under a lease the caller already holds. Package-internal: the driver
|
|
103
|
+
* calls it between batches and in an in-place resync, and `reconcileFeed` calls it
|
|
104
|
+
* after taking the lease itself.
|
|
105
|
+
*
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export const reconcileWith = <D, Receipt>(args: {
|
|
109
|
+
readonly source: FeedSource<D>
|
|
110
|
+
readonly sink: FeedSink<D, Receipt>
|
|
111
|
+
readonly reconcile: FeedReconcile
|
|
112
|
+
readonly lease: FeedLease
|
|
113
|
+
readonly pageSize: number
|
|
114
|
+
}): Effect.Effect<
|
|
115
|
+
Reconciled,
|
|
116
|
+
SinkRetryable | SinkHalted | LeaseLost | FeedUnreadable
|
|
117
|
+
> =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const listed = yield* Stream.runCollect(args.reconcile.listKeys)
|
|
120
|
+
const live = yield* liveKeys(args.source, args.pageSize)
|
|
121
|
+
const orphans = Array.from(new Set(listed)).filter((key) => !live.has(key))
|
|
122
|
+
const deleted: Array<FeedKey> = []
|
|
123
|
+
const refused: Array<{ key: FeedKey; reason: string }> = []
|
|
124
|
+
for (const chunk of Arr.chunksOf(orphans, args.pageSize)) {
|
|
125
|
+
const pending: NonEmptyReadonlyArray<Pending<D>> = Arr.map(
|
|
126
|
+
chunk,
|
|
127
|
+
(key) => ({ change: FeedChange.Delete({ key }), seq: FEED_ORIGIN }),
|
|
128
|
+
)
|
|
129
|
+
const delivered = yield* deliver({
|
|
130
|
+
send: args.sink.apply,
|
|
131
|
+
confirm: args.sink.confirm,
|
|
132
|
+
lease: args.lease,
|
|
133
|
+
pending,
|
|
134
|
+
})
|
|
135
|
+
deleted.push(...delivered.sent)
|
|
136
|
+
refused.push(
|
|
137
|
+
...delivered.parked.map((p) => ({ key: p.key, reason: p.reason })),
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
const listedKeys = new Set(listed)
|
|
141
|
+
const deletedKeys = new Set(deleted)
|
|
142
|
+
return {
|
|
143
|
+
report: { listed: listedKeys.size, deleted, refused },
|
|
144
|
+
isGone: (key) =>
|
|
145
|
+
!live.has(key) && (deletedKeys.has(key) || !listedKeys.has(key)),
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Unpark every parked key a reconcile found gone from the sink for good (see
|
|
151
|
+
* {@link Reconciled}): a park reporting a refusal for such a key — an in-place
|
|
152
|
+
* rebuild's orphan, or a tombstone whose delete the tail was refused — reports a
|
|
153
|
+
* record that is not there. `retryParked` unparks a key with no row and re-stamps
|
|
154
|
+
* one that has a row, which for a tombstone sends every sink one idempotent delete.
|
|
155
|
+
*
|
|
156
|
+
* It judges by what the sink holds, not by what this reconcile deleted, so a park
|
|
157
|
+
* an earlier unpark failed to clear goes on the next reconcile. That is why a
|
|
158
|
+
* failure here is a warning and not an error: it costs a stale entry in
|
|
159
|
+
* `inspectFeed` until then, and failing would end a worker's session, or discard
|
|
160
|
+
* an operator's report, over a record that is already gone.
|
|
161
|
+
*
|
|
162
|
+
* @internal
|
|
163
|
+
*/
|
|
164
|
+
export const unparkGone = (
|
|
165
|
+
store: FeedSyncStore,
|
|
166
|
+
id: FeedSinkId,
|
|
167
|
+
isGone: (key: FeedKey) => boolean,
|
|
168
|
+
): Effect.Effect<void> =>
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const parked = yield* store.readParked(id)
|
|
171
|
+
yield* Effect.forEach(
|
|
172
|
+
parked.filter((p) => isGone(p.key)),
|
|
173
|
+
(p) => store.retryParked({ ...id, key: p.key }),
|
|
174
|
+
{ discard: true },
|
|
175
|
+
)
|
|
176
|
+
}).pipe(
|
|
177
|
+
Effect.catch((cause) =>
|
|
178
|
+
Effect.logWarning(
|
|
179
|
+
'feed reconcile could not unpark a record it found gone; the next reconcile will',
|
|
180
|
+
{ feed: id.feed, sink: id.sink, reason: cause.message },
|
|
181
|
+
),
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
/** Options for {@link reconcileFeed}. */
|
|
186
|
+
export interface ReconcileFeedOptions<D, Receipt> {
|
|
187
|
+
readonly source: FeedSource<D>
|
|
188
|
+
readonly sink: FeedSink<D, Receipt> & { readonly reconcile: FeedReconcile }
|
|
189
|
+
readonly store: FeedSyncStore
|
|
190
|
+
/** How many rows to read, and deletes to send, at a time. */
|
|
191
|
+
readonly pageSize?: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Reconcile `sink` against `source` once, now. It takes the worker lease itself,
|
|
196
|
+
* so it fails `LeaseHeld` while a sync worker for the same feed and sink runs —
|
|
197
|
+
* reconcile through that worker's `reconcileEvery` instead, or stop it first.
|
|
198
|
+
*/
|
|
199
|
+
export const reconcileFeed = <D, Receipt>(
|
|
200
|
+
options: ReconcileFeedOptions<D, Receipt>,
|
|
201
|
+
): Effect.Effect<
|
|
202
|
+
ReconcileReport,
|
|
203
|
+
| LeaseHeld
|
|
204
|
+
| LeaseLost
|
|
205
|
+
| FeedUnreadable
|
|
206
|
+
| FeedWriteFailed
|
|
207
|
+
| SinkRetryable
|
|
208
|
+
| SinkHalted
|
|
209
|
+
> =>
|
|
210
|
+
Effect.scoped(
|
|
211
|
+
Effect.gen(function* () {
|
|
212
|
+
const id = { feed: options.source.feed, sink: options.sink.name }
|
|
213
|
+
const lease = yield* options.store.acquireLease(id)
|
|
214
|
+
const { report, isGone } = yield* reconcileWith({
|
|
215
|
+
source: options.source,
|
|
216
|
+
sink: options.sink,
|
|
217
|
+
reconcile: options.sink.reconcile,
|
|
218
|
+
lease,
|
|
219
|
+
pageSize: options.pageSize ?? DEFAULT_FEED_PAGE_SIZE,
|
|
220
|
+
})
|
|
221
|
+
yield* unparkGone(options.store, id, isGone)
|
|
222
|
+
return report
|
|
223
|
+
}),
|
|
224
|
+
)
|