@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,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.readLimit = exports.positiveDuration = exports.HELD_WAIT_WARN_POLLS = exports.DEFAULT_FEED_RESTART_MIN_DELAY = exports.DEFAULT_FEED_RESTART_MAX_DELAY = exports.DEFAULT_FEED_POLL_INTERVAL = exports.DEFAULT_FEED_PAGE_SIZE = exports.DEFAULT_FEED_MAX_NO_PROGRESS_RESTARTS = void 0;
|
|
7
|
+
var _core = require("@kairos-es/core");
|
|
8
|
+
var _effect = require("effect");
|
|
9
|
+
/**
|
|
10
|
+
* The driver's tuning: its defaults, and the checks that refuse a nonsensical
|
|
11
|
+
* value at construction rather than somewhere inside a running worker.
|
|
12
|
+
*
|
|
13
|
+
* The page size defaults to core's own catch-up page, and the supervision curve to
|
|
14
|
+
* the read side's — 100 ms doubling to 30 s — so a feed behaves like its
|
|
15
|
+
* neighbours unless told otherwise. The no-progress cap is the read side's too,
|
|
16
|
+
* and for the same reason: forty restarts on that curve is some twenty minutes of
|
|
17
|
+
* a sink refusing everything, which is long enough to ride out an outage and short
|
|
18
|
+
* enough that a broken wiring is reported the same day.
|
|
19
|
+
*
|
|
20
|
+
* Package-internal; the options a caller sets are `FeedSyncTuning`'s, on
|
|
21
|
+
* `runFeedSync`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Rows read, and changes sent, per page. */
|
|
25
|
+
const DEFAULT_FEED_PAGE_SIZE = exports.DEFAULT_FEED_PAGE_SIZE = _core.DEFAULT_CATCH_UP_PAGE_SIZE;
|
|
26
|
+
/** The upper bound on an idle worker's sleep: the poll that is the guarantee. */
|
|
27
|
+
const DEFAULT_FEED_POLL_INTERVAL = exports.DEFAULT_FEED_POLL_INTERVAL = /*#__PURE__*/_effect.Duration.seconds(1);
|
|
28
|
+
/** The first restart back-off. */
|
|
29
|
+
const DEFAULT_FEED_RESTART_MIN_DELAY = exports.DEFAULT_FEED_RESTART_MIN_DELAY = /*#__PURE__*/_effect.Duration.millis(100);
|
|
30
|
+
/** The restart back-off's cap, before jitter. */
|
|
31
|
+
const DEFAULT_FEED_RESTART_MAX_DELAY = exports.DEFAULT_FEED_RESTART_MAX_DELAY = /*#__PURE__*/_effect.Duration.seconds(30);
|
|
32
|
+
/** Consecutive restarts without a cursor advance before the worker gives up. */
|
|
33
|
+
const DEFAULT_FEED_MAX_NO_PROGRESS_RESTARTS = exports.DEFAULT_FEED_MAX_NO_PROGRESS_RESTARTS = 40;
|
|
34
|
+
/**
|
|
35
|
+
* How many poll intervals a rebuild held on its readiness waits between two
|
|
36
|
+
* warnings that it is still held. Not a bound: the wait goes on, since publishing
|
|
37
|
+
* early is the failure the wait exists to prevent. Counted in polls, not time, so
|
|
38
|
+
* it scales with `pollInterval`: sixty polls is long past any replay that is
|
|
39
|
+
* merely busy at any sensible interval, and rare enough not to flood a log while
|
|
40
|
+
* a long one runs.
|
|
41
|
+
*/
|
|
42
|
+
const HELD_WAIT_WARN_POLLS = exports.HELD_WAIT_WARN_POLLS = 60;
|
|
43
|
+
/** The page size as a `ReadLimit`, refusing one that is not a positive integer. */
|
|
44
|
+
const readLimit = pageSize => {
|
|
45
|
+
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
|
46
|
+
throw new Error(`feed: pageSize must be a positive integer (got ${pageSize})`);
|
|
47
|
+
}
|
|
48
|
+
return _core.ReadLimit.make(pageSize);
|
|
49
|
+
};
|
|
50
|
+
/** A duration input decoded, refusing one that is not positive and finite. */
|
|
51
|
+
exports.readLimit = readLimit;
|
|
52
|
+
const positiveDuration = (label, input) => {
|
|
53
|
+
const decoded = _effect.Duration.fromInputUnsafe(input);
|
|
54
|
+
if (!_effect.Duration.isFinite(decoded) || _effect.Duration.toMillis(decoded) <= 0) {
|
|
55
|
+
throw new Error(`feed: ${label} must be a positive, finite duration`);
|
|
56
|
+
}
|
|
57
|
+
return decoded;
|
|
58
|
+
};
|
|
59
|
+
exports.positiveDuration = positiveDuration;
|
|
60
|
+
//# sourceMappingURL=tuning.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tuning.js","names":["_core","require","_effect","DEFAULT_FEED_PAGE_SIZE","exports","DEFAULT_CATCH_UP_PAGE_SIZE","DEFAULT_FEED_POLL_INTERVAL","Duration","seconds","DEFAULT_FEED_RESTART_MIN_DELAY","millis","DEFAULT_FEED_RESTART_MAX_DELAY","DEFAULT_FEED_MAX_NO_PROGRESS_RESTARTS","HELD_WAIT_WARN_POLLS","readLimit","pageSize","Number","isInteger","Error","ReadLimit","make","positiveDuration","label","input","decoded","fromInputUnsafe","isFinite","toMillis"],"sources":["../../src/tuning.ts"],"sourcesContent":[null],"mappings":";;;;;;AAcA,IAAAA,KAAA,GAAAC,OAAA;AACA,IAAAC,OAAA,GAAAD,OAAA;AAfA;;;;;;;;;;;;;;;AAiBA;AACO,MAAME,sBAAsB,GAAAC,OAAA,CAAAD,sBAAA,GAAWE,gCAA0B;AAExE;AACO,MAAMC,0BAA0B,GAAAF,OAAA,CAAAE,0BAAA,gBAAsBC,gBAAQ,CAACC,OAAO,CAAC,CAAC,CAAC;AAEhF;AACO,MAAMC,8BAA8B,GAAAL,OAAA,CAAAK,8BAAA,gBACzCF,gBAAQ,CAACG,MAAM,CAAC,GAAG,CAAC;AAEtB;AACO,MAAMC,8BAA8B,GAAAP,OAAA,CAAAO,8BAAA,gBACzCJ,gBAAQ,CAACC,OAAO,CAAC,EAAE,CAAC;AAEtB;AACO,MAAMI,qCAAqC,GAAAR,OAAA,CAAAQ,qCAAA,GAAG,EAAE;AAEvD;;;;;;;;AAQO,MAAMC,oBAAoB,GAAAT,OAAA,CAAAS,oBAAA,GAAG,EAAE;AAEtC;AACO,MAAMC,SAAS,GAAIC,QAAgB,IAAe;EACvD,IAAI,CAACC,MAAM,CAACC,SAAS,CAACF,QAAQ,CAAC,IAAIA,QAAQ,IAAI,CAAC,EAAE;IAChD,MAAM,IAAIG,KAAK,CACb,kDAAkDH,QAAQ,GAAG,CAC9D;EACH;EACA,OAAOI,eAAS,CAACC,IAAI,CAACL,QAAQ,CAAC;AACjC,CAAC;AAED;AAAAX,OAAA,CAAAU,SAAA,GAAAA,SAAA;AACO,MAAMO,gBAAgB,GAAGA,CAC9BC,KAAa,EACbC,KAAqB,KACA;EACrB,MAAMC,OAAO,GAAGjB,gBAAQ,CAACkB,eAAe,CAACF,KAAK,CAAC;EAC/C,IAAI,CAAChB,gBAAQ,CAACmB,QAAQ,CAACF,OAAO,CAAC,IAAIjB,gBAAQ,CAACoB,QAAQ,CAACH,OAAO,CAAC,IAAI,CAAC,EAAE;IAClE,MAAM,IAAIN,KAAK,CAAC,SAASI,KAAK,sCAAsC,CAAC;EACvE;EACA,OAAOE,OAAO;AAChB,CAAC;AAAApB,OAAA,CAAAiB,gBAAA,GAAAA,gBAAA","ignoreList":[]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The feed's two faces: the WRITE side a read model's `apply` uses inside its
|
|
3
|
+
* commit, and the READ side a sync worker tails.
|
|
4
|
+
*
|
|
5
|
+
* ## Why the feed is written inside the materialisation's commit
|
|
6
|
+
*
|
|
7
|
+
* A feed row exists if and only if the view write and the checkpoint advance that
|
|
8
|
+
* produced it landed (ADR-0019 §2). That is the read side's existing exactly-once
|
|
9
|
+
* seam, reused rather than rebuilt: the `ProjectionStore` brackets a read model's
|
|
10
|
+
* view writes with its guarded checkpoint advance, so a feed written through the
|
|
11
|
+
* same bracket commits with the view, rolls back with it on a lost guard, and is
|
|
12
|
+
* never seen half-written. Nothing else about the feed's correctness would survive
|
|
13
|
+
* a feed written beside the commit instead of inside it.
|
|
14
|
+
*
|
|
15
|
+
* ## Why writes are batched before they land
|
|
16
|
+
*
|
|
17
|
+
* `BatchedFeedWriter.write` hands its body a `FeedWriter`, collects every upsert
|
|
18
|
+
* and remove the body makes, and lands them together when the body succeeds:
|
|
19
|
+
* the last write per key wins within one call. Two reasons, one per kind of store.
|
|
20
|
+
*
|
|
21
|
+
* - A transactional store would roll a part-written batch back on its own; there
|
|
22
|
+
* coalescing is purely an economy, one row write per key instead of one per
|
|
23
|
+
* event.
|
|
24
|
+
* - A non-transactional store cannot roll anything back, and the `ProjectionStore`
|
|
25
|
+
* port's contract therefore asks that such a store's per-batch view write be ONE
|
|
26
|
+
* atomic effect. Buffering is what makes the feed's half of the batch one atomic
|
|
27
|
+
* effect too, so a body that fails half-way leaves no feed row behind.
|
|
28
|
+
*
|
|
29
|
+
* ## What "coalesced, latest wins" means for the stored row
|
|
30
|
+
*
|
|
31
|
+
* One row per key. An upsert of a document EQUAL to the stored one writes nothing
|
|
32
|
+
* and takes no sequence value, so an event that changes nothing a sink indexes
|
|
33
|
+
* costs the sink nothing (ADR-0019 §4). Equality is of the ENCODED document, as a
|
|
34
|
+
* JSON value whose object keys are unordered, so what counts as a change is the
|
|
35
|
+
* same in every backend. A remove leaves a TOMBSTONE — the row, with no document —
|
|
36
|
+
* so every sink's cursor can pass it and send the delete, and a remove of a key
|
|
37
|
+
* that is absent or already a tombstone writes nothing.
|
|
38
|
+
*/
|
|
39
|
+
import type { Position, ReadLimit } from '@kairos-es/core';
|
|
40
|
+
import type { Effect, Option, Stream } from 'effect';
|
|
41
|
+
import type { FeedUnreadable, FeedWriteFailed } from './errors.js';
|
|
42
|
+
import type { FeedKey, FeedName, FeedSeq, Generation } from './identifiers.js';
|
|
43
|
+
/**
|
|
44
|
+
* One row of a feed: the newest state of one record. `doc` is `None` for a
|
|
45
|
+
* tombstone. `eventPosition` is the position of the event batch that last wrote
|
|
46
|
+
* the row, kept for "the sink is at least as fresh as P" and never used as a
|
|
47
|
+
* cursor; `seq` is the cursor.
|
|
48
|
+
*/
|
|
49
|
+
export interface FeedRow<D> {
|
|
50
|
+
readonly key: FeedKey;
|
|
51
|
+
readonly doc: Option.Option<D>;
|
|
52
|
+
readonly seq: FeedSeq;
|
|
53
|
+
readonly eventPosition: Position;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What a read model's `apply` writes through, for each search record the batch
|
|
57
|
+
* touches. Used only inside {@link BatchedFeedWriter.write}'s body.
|
|
58
|
+
*/
|
|
59
|
+
export interface FeedWriter<D> {
|
|
60
|
+
/** The record's newest state. Writes nothing when the stored document is equal. */
|
|
61
|
+
readonly upsert: (key: FeedKey, doc: D) => Effect.Effect<void, FeedWriteFailed>;
|
|
62
|
+
/** A tombstone. Writes nothing when the key is absent or already a tombstone. */
|
|
63
|
+
readonly remove: (key: FeedKey) => Effect.Effect<void, FeedWriteFailed>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A feed's write side: run `body` with a {@link FeedWriter}, and land what it
|
|
67
|
+
* wrote, coalesced by key, when it succeeds. `at` becomes every written row's
|
|
68
|
+
* `eventPosition`; pass the position of the last event in the batch being
|
|
69
|
+
* applied. `RW` is what the backend itself needs from the context — a SQL backend
|
|
70
|
+
* writes through the read model's own client, so the client is in `RW` and the
|
|
71
|
+
* write joins whatever transaction the `ProjectionStore` opened around `apply`.
|
|
72
|
+
*
|
|
73
|
+
* Call it from inside the materialisation's `commit` and nowhere else: a feed
|
|
74
|
+
* written outside the commit is a feed that can disagree with its view.
|
|
75
|
+
*/
|
|
76
|
+
export interface BatchedFeedWriter<D, RW = never> {
|
|
77
|
+
readonly feed: FeedName;
|
|
78
|
+
readonly write: <A, E, R>(at: Position, body: (writer: FeedWriter<D>) => Effect.Effect<A, E, R>) => Effect.Effect<A, E | FeedWriteFailed, R | RW>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A feed's read side, as the sync worker sees it.
|
|
82
|
+
*
|
|
83
|
+
* `changesAfter` returns the rows whose `seq` is strictly greater than `after`, in
|
|
84
|
+
* ascending `seq` order, at most `limit` of them, tombstones included. Its one
|
|
85
|
+
* obligation is the subscribe machine's: a row committed and visible must never
|
|
86
|
+
* have a `seq` below one already returned. A feed bound to one materialisation
|
|
87
|
+
* keeps it, because that materialisation has one writer.
|
|
88
|
+
*
|
|
89
|
+
* `listen`, when present, is a WAKE and nothing more: each element says "there
|
|
90
|
+
* may be new rows", and a missed, duplicated or coalesced one makes a read early
|
|
91
|
+
* or late but never wrong, because the poll is the guarantee.
|
|
92
|
+
*/
|
|
93
|
+
export interface FeedSource<D> {
|
|
94
|
+
readonly feed: FeedName;
|
|
95
|
+
readonly generation: Effect.Effect<Generation, FeedUnreadable>;
|
|
96
|
+
readonly changesAfter: (after: FeedSeq, limit: ReadLimit) => Effect.Effect<ReadonlyArray<FeedRow<D>>, FeedUnreadable>;
|
|
97
|
+
readonly listen?: Stream.Stream<unknown, FeedUnreadable>;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=Feed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Feed.d.ts","sourceRoot":"","sources":["../../src/Feed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAE3E;;;;;GAKG;AACH,MAAM,WAAW,OAAO,CAAC,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAC9B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,aAAa,EAAE,QAAQ,CAAA;CACjC;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,CACf,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,CAAC,KACH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAAC,CAAA;IACzC,iFAAiF;IACjF,QAAQ,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,eAAe,CAAC,CAAA;CACxE;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB,CAAC,CAAC,EAAE,EAAE,GAAG,KAAK;IAC9C,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EACtB,EAAE,EAAE,QAAQ,EACZ,IAAI,EAAE,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KACpD,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,GAAG,eAAe,EAAE,CAAC,GAAG,EAAE,CAAC,CAAA;CACnD;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;IAC9D,QAAQ,CAAC,YAAY,EAAE,CACrB,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,SAAS,KACb,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,cAAc,CAAC,CAAA;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,cAAc,CAAC,CAAA;CACzD"}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sink port: what a target a feed is copied to must offer the driver.
|
|
3
|
+
*
|
|
4
|
+
* ## Three parts, two of them optional
|
|
5
|
+
*
|
|
6
|
+
* - `apply` and `confirm` are the whole of the steady state. `apply` sends a
|
|
7
|
+
* batch and returns a `Receipt`; `confirm` resolves once that batch is applied
|
|
8
|
+
* AND VISIBLE in the target. The driver moves its cursor only after `confirm`
|
|
9
|
+
* (ADR-0019 §6), so a crash between the two resends the batch — which is why
|
|
10
|
+
* every sink must be IDEMPOTENT: the same batch applied twice leaves the target
|
|
11
|
+
* as once. Full-record replace satisfies that; a partial operation such as an
|
|
12
|
+
* increment does not, and a sink must never use one.
|
|
13
|
+
* - `resync` is how a sink rebuilds after the feed's generation moves: load every
|
|
14
|
+
* row into a fresh target that is not yet serving — tombstones too, as deletes,
|
|
15
|
+
* since the feed can delete a key the same resync has already loaded — then swap
|
|
16
|
+
* it over the live one atomically, so search keeps serving the old copy until the new one is
|
|
17
|
+
* complete and records deleted from the feed do not survive the rebuild.
|
|
18
|
+
* - `reconcile` lists the keys the target holds, so the driver can delete those
|
|
19
|
+
* the feed no longer has. Deletes go through the ordinary `apply`.
|
|
20
|
+
*
|
|
21
|
+
* A sink with no atomic swap leaves out `resync`, and the driver then resyncs IN
|
|
22
|
+
* PLACE: it sends every row to the live target and reconciles away what the feed
|
|
23
|
+
* no longer holds. That needs `reconcile`, so `runFeedSync` refuses at
|
|
24
|
+
* construction a sink that has neither part: such a sink could never be rebuilt.
|
|
25
|
+
*
|
|
26
|
+
* ## Why `FeedChange` is not `FeedRow`
|
|
27
|
+
*
|
|
28
|
+
* A sink is handed the change to make, not the row it came from: an `Upsert` with
|
|
29
|
+
* the document or a `Delete`. The sequence and the event position are the
|
|
30
|
+
* driver's business, and a sink that could see them could be tempted to keep a
|
|
31
|
+
* cursor of its own, which would be a second record of progress.
|
|
32
|
+
*/
|
|
33
|
+
import { Data, type Effect, type Stream } from 'effect';
|
|
34
|
+
import type { NonEmptyReadonlyArray } from 'effect/Array';
|
|
35
|
+
import type { SinkHalted, SinkRejected, SinkRetryable } from './errors.js';
|
|
36
|
+
import type { FeedRow } from './Feed.js';
|
|
37
|
+
import type { FeedKey, Generation, SinkName } from './identifiers.js';
|
|
38
|
+
/** One change for a sink to make: the record's newest state, or its removal. */
|
|
39
|
+
export type FeedChange<D> = Data.TaggedEnum<{
|
|
40
|
+
Upsert: {
|
|
41
|
+
readonly key: FeedKey;
|
|
42
|
+
readonly doc: D;
|
|
43
|
+
};
|
|
44
|
+
Delete: {
|
|
45
|
+
readonly key: FeedKey;
|
|
46
|
+
};
|
|
47
|
+
}>;
|
|
48
|
+
/** The generic definition behind {@link FeedChange}'s constructors. */
|
|
49
|
+
export interface FeedChangeDefinition extends Data.TaggedEnum.WithGenerics<1> {
|
|
50
|
+
readonly taggedEnum: FeedChange<this['A']>;
|
|
51
|
+
}
|
|
52
|
+
/** `FeedChange.Upsert({ key, doc })`, `FeedChange.Delete({ key })`, and matchers. */
|
|
53
|
+
export declare const FeedChange: {
|
|
54
|
+
readonly Upsert: <A>(args: {
|
|
55
|
+
readonly key: FeedKey;
|
|
56
|
+
readonly doc: A;
|
|
57
|
+
}) => {
|
|
58
|
+
readonly _tag: "Upsert";
|
|
59
|
+
readonly key: FeedKey;
|
|
60
|
+
readonly doc: A;
|
|
61
|
+
};
|
|
62
|
+
readonly Delete: <A>(args: {
|
|
63
|
+
readonly key: FeedKey;
|
|
64
|
+
}) => {
|
|
65
|
+
readonly _tag: "Delete";
|
|
66
|
+
readonly key: FeedKey;
|
|
67
|
+
};
|
|
68
|
+
readonly $is: <Tag extends "Upsert" | "Delete">(tag: Tag) => {
|
|
69
|
+
<T extends {
|
|
70
|
+
readonly _tag: "Upsert";
|
|
71
|
+
readonly key: FeedKey;
|
|
72
|
+
readonly doc: any;
|
|
73
|
+
} | {
|
|
74
|
+
readonly _tag: "Delete";
|
|
75
|
+
readonly key: FeedKey;
|
|
76
|
+
}>(u: T): u is T & {
|
|
77
|
+
readonly _tag: Tag;
|
|
78
|
+
};
|
|
79
|
+
(u: unknown): u is Extract<{
|
|
80
|
+
readonly _tag: "Upsert";
|
|
81
|
+
readonly key: FeedKey;
|
|
82
|
+
readonly doc: unknown;
|
|
83
|
+
}, {
|
|
84
|
+
readonly _tag: Tag;
|
|
85
|
+
}> | Extract<{
|
|
86
|
+
readonly _tag: "Delete";
|
|
87
|
+
readonly key: FeedKey;
|
|
88
|
+
}, {
|
|
89
|
+
readonly _tag: Tag;
|
|
90
|
+
}>;
|
|
91
|
+
};
|
|
92
|
+
readonly $match: {
|
|
93
|
+
<A, B, C, D, Cases extends {
|
|
94
|
+
readonly Upsert: (args: {
|
|
95
|
+
readonly _tag: "Upsert";
|
|
96
|
+
readonly key: FeedKey;
|
|
97
|
+
readonly doc: A;
|
|
98
|
+
}) => any;
|
|
99
|
+
readonly Delete: (args: {
|
|
100
|
+
readonly _tag: "Delete";
|
|
101
|
+
readonly key: FeedKey;
|
|
102
|
+
}) => any;
|
|
103
|
+
}>(cases: Cases): (self: {
|
|
104
|
+
readonly _tag: "Upsert";
|
|
105
|
+
readonly key: FeedKey;
|
|
106
|
+
readonly doc: A;
|
|
107
|
+
} | {
|
|
108
|
+
readonly _tag: "Delete";
|
|
109
|
+
readonly key: FeedKey;
|
|
110
|
+
}) => import("effect/Unify").Unify<ReturnType<Cases["Upsert" | "Delete"]>>;
|
|
111
|
+
<A, B, C, D, Cases extends {
|
|
112
|
+
readonly Upsert: (args: {
|
|
113
|
+
readonly _tag: "Upsert";
|
|
114
|
+
readonly key: FeedKey;
|
|
115
|
+
readonly doc: A;
|
|
116
|
+
}) => any;
|
|
117
|
+
readonly Delete: (args: {
|
|
118
|
+
readonly _tag: "Delete";
|
|
119
|
+
readonly key: FeedKey;
|
|
120
|
+
}) => any;
|
|
121
|
+
}>(self: {
|
|
122
|
+
readonly _tag: "Upsert";
|
|
123
|
+
readonly key: FeedKey;
|
|
124
|
+
readonly doc: A;
|
|
125
|
+
} | {
|
|
126
|
+
readonly _tag: "Delete";
|
|
127
|
+
readonly key: FeedKey;
|
|
128
|
+
}, cases: Cases): import("effect/Unify").Unify<ReturnType<Cases["Upsert" | "Delete"]>>;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
/** The change a row asks of a sink: its document as an upsert, a tombstone as a delete. */
|
|
132
|
+
export declare const changeOf: <D>(row: FeedRow<D>) => FeedChange<D>;
|
|
133
|
+
/**
|
|
134
|
+
* Where ONE resync attempt is being built. `name` is the sink's own name for it (an
|
|
135
|
+
* index name, say), and it is the attempt's alone: no two `begin`s return the same
|
|
136
|
+
* one, even for one generation. So a worker that has lost its lease without yet
|
|
137
|
+
* knowing it can only ever publish its OWN target, never the half-built target of
|
|
138
|
+
* the worker that holds the lease now; `runFeedSync.ts` owns what that buys.
|
|
139
|
+
*/
|
|
140
|
+
export interface ResyncTarget {
|
|
141
|
+
readonly generation: Generation;
|
|
142
|
+
readonly name: string;
|
|
143
|
+
}
|
|
144
|
+
/** A sink's rebuild: a fresh target, loaded, then swapped over the live one. */
|
|
145
|
+
export interface FeedResync<D, Receipt> {
|
|
146
|
+
/**
|
|
147
|
+
* A fresh, empty target that is not live, under a name no earlier `begin`
|
|
148
|
+
* returned. Drops every other target an earlier attempt left — crashed,
|
|
149
|
+
* abandoned, or still being loaded by a worker that lost its lease — whose
|
|
150
|
+
* `publish` must then fail retryably rather than touch the live copy.
|
|
151
|
+
*/
|
|
152
|
+
readonly begin: (generation: Generation) => Effect.Effect<ResyncTarget, SinkRetryable | SinkHalted>;
|
|
153
|
+
/**
|
|
154
|
+
* `apply`, against the target rather than the live copy. Confirmed by `confirm`.
|
|
155
|
+
* A target a later `begin` dropped must NEVER BE PUBLISHED, since the worker
|
|
156
|
+
* loading into it can be one that lost its lease without knowing it, and a target
|
|
157
|
+
* rebuilt from its later pages alone must never be swapped over the live copy as
|
|
158
|
+
* a fragment. Failing `SinkRetryable` here is the plain way to keep that. A sink
|
|
159
|
+
* whose writes recreate a missing target (an index that springs into being on its
|
|
160
|
+
* first write) may let the load land and recreate it instead, provided `publish`
|
|
161
|
+
* then tells the recreated target from the one `begin` made and refuses it.
|
|
162
|
+
*/
|
|
163
|
+
readonly load: (target: ResyncTarget, batch: NonEmptyReadonlyArray<FeedChange<D>>) => Effect.Effect<Receipt, SinkRetryable | SinkRejected | SinkHalted>;
|
|
164
|
+
/**
|
|
165
|
+
* Swap the target over the live copy, atomically, and resolve once it serves. A
|
|
166
|
+
* target a later `begin` dropped — gone, or brought back by a load — fails
|
|
167
|
+
* `SinkRetryable` and leaves the live copy as it was. Once the swap has landed, a
|
|
168
|
+
* sink may tidy the live copy of its own bookkeeping (a marker it set to tell its
|
|
169
|
+
* target apart) on a best-effort basis: the swap is the publish, so a failure of
|
|
170
|
+
* that tidying must not fail it.
|
|
171
|
+
*/
|
|
172
|
+
readonly publish: (target: ResyncTarget) => Effect.Effect<void, SinkRetryable | SinkHalted>;
|
|
173
|
+
}
|
|
174
|
+
/** A sink's key listing, for deleting what the feed no longer holds. */
|
|
175
|
+
export interface FeedReconcile {
|
|
176
|
+
/** Every key the live target holds, in any order, each once. */
|
|
177
|
+
readonly listKeys: Stream.Stream<FeedKey, SinkRetryable | SinkHalted>;
|
|
178
|
+
}
|
|
179
|
+
/** A target a feed is copied to. See the module doc. */
|
|
180
|
+
export interface FeedSink<D, Receipt> {
|
|
181
|
+
readonly name: SinkName;
|
|
182
|
+
readonly apply: (batch: NonEmptyReadonlyArray<FeedChange<D>>) => Effect.Effect<Receipt, SinkRetryable | SinkRejected | SinkHalted>;
|
|
183
|
+
/** Resolves once the batch the receipt names is applied and visible. */
|
|
184
|
+
readonly confirm: (receipt: Receipt) => Effect.Effect<void, SinkRetryable | SinkHalted>;
|
|
185
|
+
/** Absent: the driver resyncs in place, which needs `reconcile`. */
|
|
186
|
+
readonly resync?: FeedResync<D, Receipt>;
|
|
187
|
+
readonly reconcile?: FeedReconcile;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=FeedSink.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedSink.d.ts","sourceRoot":"","sources":["../../src/FeedSink.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FeedSyncStore` — the port for a sync worker's durable state, kept in the SOURCE
|
|
3
|
+
* database beside the feed rows: one cursor per feed and sink, the parked list,
|
|
4
|
+
* and the worker lease (ADR-0019 §10).
|
|
5
|
+
*
|
|
6
|
+
* The in-memory feed implements it as the parity oracle, and `@kairos-es/feed-postgres`
|
|
7
|
+
* implements it over its cursor and parked tables. Everything below is the
|
|
8
|
+
* contract both must keep; the driver relies on no more than this.
|
|
9
|
+
*
|
|
10
|
+
* ## The lease, and why a feed takes one where a projection does not
|
|
11
|
+
*
|
|
12
|
+
* A projection needs no lease (ADR-0007): its view is transactional, so a rival
|
|
13
|
+
* runner's write is rolled back by the guarded checkpoint advance before anyone
|
|
14
|
+
* sees it. A sink is not transactional. Two workers on one sink can both send
|
|
15
|
+
* before either moves the cursor, and an older read sent late overwrites a newer
|
|
16
|
+
* record, which no compare-and-set run AFTER the send can undo. So one worker per
|
|
17
|
+
* feed and sink holds a lease, and sends nothing without it (ADR-0019 §5).
|
|
18
|
+
*
|
|
19
|
+
* `acquireLease` is SCOPED. It either takes the lease or fails `LeaseHeld` at
|
|
20
|
+
* once — it never waits — and the lease is released when the scope closes. What
|
|
21
|
+
* it returns is a {@link FeedLease}: the holder id the store issued, and
|
|
22
|
+
* `ensureHeld`, which the driver runs before EVERY call that changes the sink —
|
|
23
|
+
* `apply`, and a resync's `begin`, `load` and `publish` — and which fails
|
|
24
|
+
* `LeaseLost` the moment the store can tell the lease is no longer this worker's.
|
|
25
|
+
* How the store keeps the lease alive, and how it tells, is its own business, and
|
|
26
|
+
* there are two shapes it may take:
|
|
27
|
+
*
|
|
28
|
+
* - **A lease row with an expiry**, renewed by a heartbeat the store forks into the
|
|
29
|
+
* lease's scope. `ensureHeld` fails once a renewal has found the row taken, and
|
|
30
|
+
* once the lease is within a SAFETY MARGIN of the expiry it last stored — never
|
|
31
|
+
* only at the expiry itself. This works through a transaction pooler and needs no
|
|
32
|
+
* reserved connection. Its cost is a window, and the margin is what bounds it:
|
|
33
|
+
* `ensureHeld` judges the lease when a call STARTS, and nothing stops a call
|
|
34
|
+
* already in flight when the lease lapses. A call that ends within the margin of
|
|
35
|
+
* the check that let it through lands while the lease is still this worker's,
|
|
36
|
+
* and cannot be overtaken by a new holder's, renewals failing meanwhile or not.
|
|
37
|
+
* A call that takes longer than the margin — a slow sink call, one retrying its
|
|
38
|
+
* hosts, or a process pause between the check and the call — can land after the
|
|
39
|
+
* lease has lapsed and after the new holder's own calls. That is ONE stale call,
|
|
40
|
+
* since the worker's next `ensureHeld` fails, and it can land as late as the
|
|
41
|
+
* call's duration less the margin past the lapse; no pause longer than the lease
|
|
42
|
+
* is needed, only a call longer than the margin while renewals are failing. In
|
|
43
|
+
* the tail that is one stale batch. In a resync it
|
|
44
|
+
* can be a `publish`, which swaps in the worker's whole target, complete as of
|
|
45
|
+
* its own read of the feed: if the new holder has already published and moved its
|
|
46
|
+
* cursor on, every record that changed in between is served at its older state —
|
|
47
|
+
* a key deleted since comes back, one created since is missing — until it changes
|
|
48
|
+
* again or the next resync. A reconcile removes the keys that came back, and
|
|
49
|
+
* repairs nothing else. It can be a `begin`, which drops the new holder's target
|
|
50
|
+
* and costs that holder a restarted resync. It is never a `publish` of another
|
|
51
|
+
* worker's target, because each `begin` names a target of its own
|
|
52
|
+
* (`runFeedSync.ts` owns why).
|
|
53
|
+
* - **A session lock on a dedicated connection.** `ensureHeld` checks the
|
|
54
|
+
* connection is still alive. A process pause opens no window of its own, because
|
|
55
|
+
* the lock survives it and goes only when the session goes. The window is the
|
|
56
|
+
* session dying while a call is in flight, or between the check and the call:
|
|
57
|
+
* that call is let through, and the new holder can take the lock the moment the
|
|
58
|
+
* session is gone. It is the same ONE stale call, at the same costs as above,
|
|
59
|
+
* since the next `ensureHeld` asks a session that is gone, and it can land as
|
|
60
|
+
* late as that call's whole duration after the session died — which, for a sink
|
|
61
|
+
* call that retries its hosts, can be long. The price of this shape is the
|
|
62
|
+
* connection.
|
|
63
|
+
*
|
|
64
|
+
* Both shapes record the holder where the cursor compare-and-set can see it, and
|
|
65
|
+
* `casCursor` FENCES on it: a call whose `holder` is not the current holder fails
|
|
66
|
+
* `CursorSuperseded` and lands nothing. That is what stops a worker that lost its
|
|
67
|
+
* lease from moving the cursor, whichever shape the lease takes. The in-memory
|
|
68
|
+
* feed takes a third, trivial shape: the holder recorded in its one state, taken
|
|
69
|
+
* only when no holder is recorded, in the same atomic step as that check, and
|
|
70
|
+
* cleared by the finaliser of the lease's scope.
|
|
71
|
+
*
|
|
72
|
+
* ## The cursor
|
|
73
|
+
*
|
|
74
|
+
* A cursor is a `(generation, seq)` pair; a sink with no cursor yet has none, and
|
|
75
|
+
* resyncs. `casCursor` is guarded like a checkpoint: it lands only when the stored
|
|
76
|
+
* cursor EQUALS `expected` (both absent, or both present with an equal generation
|
|
77
|
+
* and an equal seq) AND the lease is held by `holder`. It moves the parked list in
|
|
78
|
+
* the SAME atomic step, so a park recorded for a batch can never outlive a cursor
|
|
79
|
+
* advance that was refused, nor be lost from one that landed.
|
|
80
|
+
*
|
|
81
|
+
* An advance that lands also records WHEN it landed, as the sink's `lastAdvance`,
|
|
82
|
+
* in that same step; a refused one records nothing. The time is read from the
|
|
83
|
+
* Effect `Clock` of the process making the call (`DateTime.now`), never from the
|
|
84
|
+
* database's own clock, so `TestClock` governs it exactly as it governs the
|
|
85
|
+
* lease's expiry, and every backend reports one clock. Taking a lease is not an
|
|
86
|
+
* advance: a sink whose cursor has never moved has no `lastAdvance`.
|
|
87
|
+
*
|
|
88
|
+
* ## The parked list
|
|
89
|
+
*
|
|
90
|
+
* Parks are written only through `casCursor`, as a {@link ParkedChange}:
|
|
91
|
+
*
|
|
92
|
+
* - `Amend` upserts each entry in `park` (a key already parked takes the new seq,
|
|
93
|
+
* reason and time) and deletes each key in `unpark` that is parked. A key never
|
|
94
|
+
* appears in both. The driver unparks every key it sent successfully, which is
|
|
95
|
+
* how a parked record leaves the list once a later change of it syncs.
|
|
96
|
+
* - `Replace` discards the whole list for this feed and sink, then parks `park`.
|
|
97
|
+
* A resync uses it, because a resync re-sends every record and the parks it
|
|
98
|
+
* meets are the only ones still true; `park` holds a key only when the last
|
|
99
|
+
* change of it the resync sent was refused.
|
|
100
|
+
*
|
|
101
|
+
* `retryParked` deletes one park AND re-stamps the row's `seq` with a fresh value,
|
|
102
|
+
* in one atomic step, so the next pass of every sink on the feed sends it again;
|
|
103
|
+
* the other sinks receive a record they already hold, which an idempotent sink
|
|
104
|
+
* absorbs. A key with no row left is simply unparked.
|
|
105
|
+
*
|
|
106
|
+
* ## Tombstone collection
|
|
107
|
+
*
|
|
108
|
+
* `collectTombstones(feed)` deletes the tombstone rows whose `seq` is at or below
|
|
109
|
+
* the LOWEST cursor `seq` over EVERY sink registered on the feed — every sink the
|
|
110
|
+
* store holds a cursor or a lease for — and reports how many it deleted. The
|
|
111
|
+
* minimum is taken over every registered sink's cursor WHATEVER generation it was
|
|
112
|
+
* saved at. A registered sink whose cursor is ABSENT blocks collection entirely: it
|
|
113
|
+
* counts as below every tombstone, and nothing is deleted until it has saved one.
|
|
114
|
+
* No sink registered, nothing deleted.
|
|
115
|
+
*
|
|
116
|
+
* A cursor at an older generation needs no rule of its own, because the minimum
|
|
117
|
+
* already holds it below every tombstone. A reset empties the feed in the same
|
|
118
|
+
* atomic step as it moves the generation, and `seq` never goes back, so every row
|
|
119
|
+
* the feed holds after a reset carries a `seq` above every value the feed reached
|
|
120
|
+
* before it. And a cursor is saved at a generation only with a `seq` read while the
|
|
121
|
+
* feed was at that generation: the driver reads the generation AFTER reading the
|
|
122
|
+
* rows and before saving, so rows read after a reset are never saved under the
|
|
123
|
+
* generation the reset left. So a cursor at an older generation is below every row
|
|
124
|
+
* the feed now holds, tombstones included, and holds collection back until its
|
|
125
|
+
* sink saves a cursor at the current generation. An arm of its own could only
|
|
126
|
+
* restate that, and could never be seen to differ from it.
|
|
127
|
+
*
|
|
128
|
+
* Such a sink is resyncing, as is one with no cursor, and the obvious reading —
|
|
129
|
+
* that a resync starts from the origin and so needs no tombstone — is wrong. A
|
|
130
|
+
* resync reads the feed page by page while the feed keeps moving, and it saves its
|
|
131
|
+
* cursor at the last `seq` it READ, not at the head. A key it loaded on an early
|
|
132
|
+
* page can be deleted after its last page was read; another sink passes that
|
|
133
|
+
* tombstone; and were it collected then, the resync would publish the key and
|
|
134
|
+
* start its tail at a cursor below a delete that no longer exists, holding the
|
|
135
|
+
* record for good. So the rule counts every registered sink, and a resync holds
|
|
136
|
+
* collection back until its cursor is saved. The cost is that tombstones
|
|
137
|
+
* accumulate while any sink resyncs — or is registered and never run again, which
|
|
138
|
+
* holds collection back until the sink is removed (below). `lastAdvance` in a
|
|
139
|
+
* {@link FeedSyncDescription} is how such a sink shows.
|
|
140
|
+
*
|
|
141
|
+
* The store computes the lowest cursor itself, in the same step as the delete, so
|
|
142
|
+
* a cursor moving between the two cannot let a tombstone go that some sink still
|
|
143
|
+
* needs.
|
|
144
|
+
*
|
|
145
|
+
* ## Removing a sink
|
|
146
|
+
*
|
|
147
|
+
* `removeSink(id)` deregisters a sink the application has retired. In ONE atomic
|
|
148
|
+
* step it deletes everything the store holds for that feed and sink — its cursor,
|
|
149
|
+
* its `lastAdvance`, its lease record and every parked record — and succeeds with
|
|
150
|
+
* whether there was anything to delete. A registered sink holds tombstone
|
|
151
|
+
* collection back for as long as it is registered, so a retired one left
|
|
152
|
+
* registered holds it back for good; this is the way out, and the next
|
|
153
|
+
* `collectTombstones` takes its minimum over the sinks that remain.
|
|
154
|
+
*
|
|
155
|
+
* It is REFUSED with `LeaseHeld`, deleting nothing, while a LIVE worker holds the
|
|
156
|
+
* sink's lease. Deleting the cursor under a running worker would only make it
|
|
157
|
+
* resync from the origin and register the sink again: stop the worker, then
|
|
158
|
+
* remove the sink. Whether the lease is live is the lease's own test, made in the
|
|
159
|
+
* same atomic step as the delete, so no worker can take the lease between the
|
|
160
|
+
* test and the delete:
|
|
161
|
+
*
|
|
162
|
+
* - **A lease row with an expiry** is live while its expiry is later than the
|
|
163
|
+
* caller's `Clock` now. A lapsed row is not live, and is deleted with the rest.
|
|
164
|
+
* - **A session lock** is live while any session holds the lock, which the row
|
|
165
|
+
* cannot say. So the store tries the lock ITSELF, on the same key, for the length
|
|
166
|
+
* of the removing transaction (a transaction-scoped try-lock), and refuses when
|
|
167
|
+
* it cannot have it. A holder recorded on the row while the store CAN have the
|
|
168
|
+
* lock belongs to a session that has gone, and is deleted with the rest.
|
|
169
|
+
* - **The in-memory feed** refuses while a holder is recorded.
|
|
170
|
+
*
|
|
171
|
+
* Each test belongs to the kind of lease the HOLDER took, not to the kind the
|
|
172
|
+
* store was built to take: where one sink can be leased either way, by workers
|
|
173
|
+
* built differently, the store makes every test that applies, since a row alone
|
|
174
|
+
* cannot say which kind is holding it.
|
|
175
|
+
*
|
|
176
|
+
* Nothing about the fence changes. A worker that lost its lease without knowing it
|
|
177
|
+
* finds no holder recorded after a removal, so its `casCursor` fails
|
|
178
|
+
* `CursorSuperseded`, as it would against any other holder. And a worker started
|
|
179
|
+
* for the sink again simply registers it anew, with no cursor, and resyncs: a
|
|
180
|
+
* removal leaves no mark that would refuse it.
|
|
181
|
+
*
|
|
182
|
+
* ## Description
|
|
183
|
+
*
|
|
184
|
+
* `describe(feed)` is everything `inspectFeed` reports that lives in the store, in
|
|
185
|
+
* one read: the feed's head (its highest stored `seq`, `FEED_ORIGIN` when it holds
|
|
186
|
+
* no row) and, per sink with a cursor or a lease, the cursor, when it last moved
|
|
187
|
+
* (`lastAdvance`), the lease, the parked list and the number of rows still pending
|
|
188
|
+
* for it.
|
|
189
|
+
*
|
|
190
|
+
* ## Errors
|
|
191
|
+
*
|
|
192
|
+
* An infrastructure fault is `FeedUnreadable` on a read and `FeedWriteFailed` on a
|
|
193
|
+
* write, never a defect, so the driver's supervision can back off and try again.
|
|
194
|
+
* A `FeedSinkId` naming a feed the store does not serve is a wiring defect, and a
|
|
195
|
+
* store may die on it.
|
|
196
|
+
*/
|
|
197
|
+
import type { DateTime, Effect, Option, Scope } from 'effect';
|
|
198
|
+
import type { CursorSuperseded, FeedUnreadable, FeedWriteFailed, LeaseHeld, LeaseLost } from './errors.js';
|
|
199
|
+
import type { FeedKey, FeedName, FeedSeq, FeedSinkId, Generation, LeaseHolder, SinkName } from './identifiers.js';
|
|
200
|
+
/** How far one sink has been synced: the generation, and the last confirmed seq. */
|
|
201
|
+
export interface FeedCursor {
|
|
202
|
+
readonly generation: Generation;
|
|
203
|
+
readonly seq: FeedSeq;
|
|
204
|
+
}
|
|
205
|
+
/** Whether two optional cursors are the same cursor, as `casCursor` compares them. */
|
|
206
|
+
export declare const sameCursor: (left: Option.Option<FeedCursor>, right: Option.Option<FeedCursor>) => boolean;
|
|
207
|
+
/** A held lease, as the worker that holds it sees it. */
|
|
208
|
+
export interface FeedLease {
|
|
209
|
+
readonly holder: LeaseHolder;
|
|
210
|
+
/** Fails `LeaseLost` once the lease is no longer this holder's. Run before every send. */
|
|
211
|
+
readonly ensureHeld: Effect.Effect<void, LeaseLost | FeedUnreadable>;
|
|
212
|
+
}
|
|
213
|
+
/** One parked record: the key, the `seq` of the row the sink refused, and why. */
|
|
214
|
+
export interface ParkedRecord {
|
|
215
|
+
readonly key: FeedKey;
|
|
216
|
+
readonly seq: FeedSeq;
|
|
217
|
+
readonly reason: string;
|
|
218
|
+
readonly parkedAt: DateTime.Utc;
|
|
219
|
+
}
|
|
220
|
+
/** How a cursor advance moves the parked list; see the module doc. */
|
|
221
|
+
export type ParkedChange = {
|
|
222
|
+
readonly _tag: 'Amend';
|
|
223
|
+
readonly park: ReadonlyArray<ParkedRecord>;
|
|
224
|
+
readonly unpark: ReadonlyArray<FeedKey>;
|
|
225
|
+
} | {
|
|
226
|
+
readonly _tag: 'Replace';
|
|
227
|
+
readonly park: ReadonlyArray<ParkedRecord>;
|
|
228
|
+
};
|
|
229
|
+
/** The lease as a description reports it. `expiresAt` is `None` for a lease with no expiry. */
|
|
230
|
+
export interface LeaseDescription {
|
|
231
|
+
readonly holder: LeaseHolder;
|
|
232
|
+
readonly expiresAt: Option.Option<DateTime.Utc>;
|
|
233
|
+
}
|
|
234
|
+
/** One sink's state, as the store holds it. */
|
|
235
|
+
export interface SinkSyncState {
|
|
236
|
+
readonly sink: SinkName;
|
|
237
|
+
readonly cursor: Option.Option<FeedCursor>;
|
|
238
|
+
/**
|
|
239
|
+
* When the cursor last moved, by the `Clock` of the worker that moved it; `None`
|
|
240
|
+
* if it never has. A sink whose cursor stays still while the feed's head moves is
|
|
241
|
+
* a sink nothing runs, and it holds tombstone collection back until removed.
|
|
242
|
+
*/
|
|
243
|
+
readonly lastAdvance: Option.Option<DateTime.Utc>;
|
|
244
|
+
readonly lease: Option.Option<LeaseDescription>;
|
|
245
|
+
readonly parked: ReadonlyArray<ParkedRecord>;
|
|
246
|
+
/**
|
|
247
|
+
* The rows this sink has still to receive: those above its cursor's `seq`, or
|
|
248
|
+
* every live row when it has no cursor. Counted against the stored rows, so a
|
|
249
|
+
* cursor at an older generation counts as if it were current; `inspectFeed` is
|
|
250
|
+
* what says a resync is due.
|
|
251
|
+
*/
|
|
252
|
+
readonly pending: number;
|
|
253
|
+
}
|
|
254
|
+
/** Everything `describe` reports; see the module doc. */
|
|
255
|
+
export interface FeedSyncDescription {
|
|
256
|
+
readonly head: FeedSeq;
|
|
257
|
+
readonly sinks: ReadonlyArray<SinkSyncState>;
|
|
258
|
+
}
|
|
259
|
+
/** The port for a sync worker's durable state. See the module doc for every rule. */
|
|
260
|
+
export interface FeedSyncStore {
|
|
261
|
+
readonly acquireLease: (id: FeedSinkId) => Effect.Effect<FeedLease, LeaseHeld | FeedUnreadable | FeedWriteFailed, Scope.Scope>;
|
|
262
|
+
readonly readCursor: (id: FeedSinkId) => Effect.Effect<Option.Option<FeedCursor>, FeedUnreadable>;
|
|
263
|
+
readonly casCursor: (args: {
|
|
264
|
+
readonly feed: FeedName;
|
|
265
|
+
readonly sink: SinkName;
|
|
266
|
+
readonly holder: LeaseHolder;
|
|
267
|
+
readonly expected: Option.Option<FeedCursor>;
|
|
268
|
+
readonly next: FeedCursor;
|
|
269
|
+
readonly parked: ParkedChange;
|
|
270
|
+
}) => Effect.Effect<void, CursorSuperseded | FeedWriteFailed>;
|
|
271
|
+
readonly readParked: (id: FeedSinkId) => Effect.Effect<ReadonlyArray<ParkedRecord>, FeedUnreadable>;
|
|
272
|
+
readonly retryParked: (args: {
|
|
273
|
+
readonly feed: FeedName;
|
|
274
|
+
readonly sink: SinkName;
|
|
275
|
+
readonly key: FeedKey;
|
|
276
|
+
}) => Effect.Effect<boolean, FeedWriteFailed>;
|
|
277
|
+
readonly collectTombstones: (feed: FeedName) => Effect.Effect<number, FeedWriteFailed>;
|
|
278
|
+
readonly removeSink: (id: FeedSinkId) => Effect.Effect<boolean, LeaseHeld | FeedWriteFailed>;
|
|
279
|
+
readonly describe: (feed: FeedName) => Effect.Effect<FeedSyncDescription, FeedUnreadable>;
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=FeedSyncStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeedSyncStore.d.ts","sourceRoot":"","sources":["../../src/FeedSyncStore.ts"],"names":[],"mappings":"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"}
|