@pepys/ingest 0.3.1 → 0.4.1

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.
Files changed (33) hide show
  1. package/package.json +3 -3
  2. package/src/fact-log/make-batch-appender.ts +43 -128
  3. package/src/fact-log/memory/create-memory-fact-log.ts +1 -1
  4. package/src/fact-log/pick-attempt-step.ts +53 -0
  5. package/src/fact-log/pick-retry-step.ts +47 -0
  6. package/src/fact-log/pick-settled-attempt.ts +30 -0
  7. package/src/fact-log/read-run.ts +34 -0
  8. package/src/fact-log/s2/create-s2-fact-log.ts +17 -6
  9. package/src/index.ts +3 -3
  10. package/src/pull/collect-timed-pulls.ts +23 -0
  11. package/src/pull/cursors/build-cursor-entity.ts +8 -0
  12. package/src/pull/cursors/build-cursor-record.ts +32 -0
  13. package/src/pull/cursors/cursor-stream.ts +6 -0
  14. package/src/pull/cursors/format-pull-start.ts +13 -0
  15. package/src/pull/cursors/read-saved-cursors.ts +55 -0
  16. package/src/pull/make-pull-runner.ts +98 -51
  17. package/src/pull/with-source-lock.ts +76 -0
  18. package/src/receive/answers/build-failure-answer.ts +2 -1
  19. package/src/receive/create-ingest.ts +10 -3
  20. package/src/receive/make-receive-handler.ts +21 -37
  21. package/src/receive/pick-received-step.ts +56 -0
  22. package/src/receive/write-batch.ts +8 -6
  23. package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +8 -2
  24. package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +5 -1
  25. package/src/serve/serve-ingest.ts +43 -14
  26. package/src/serve/start-pull-timers.ts +47 -39
  27. package/src/{receive → utils}/find-batch-problem.ts +4 -1
  28. package/src/utils/pick-batch-plan.ts +37 -0
  29. package/src/{receive → utils}/plan-batch.ts +1 -1
  30. package/src/source-port/types.ts +0 -64
  31. /package/src/{fact-log → utils}/check-append-batch.ts +0 -0
  32. /package/src/{fact-log → utils}/parse-stored-body.ts +0 -0
  33. /package/src/{fact-log → utils}/plan-appends.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/ingest",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "pepys ingest: the receive routes, the fact log and the blob store",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@impz/client": "6.12.0",
25
- "@pepys/kit": "0.3.1",
26
- "@pepys/schema": "0.3.1",
25
+ "@pepys/kit": "0.4.1",
26
+ "@pepys/schema": "0.4.1",
27
27
  "@s2-dev/streamstore": "0.30.0",
28
28
  "@zgeoff/std": "0.4.3",
29
29
  "tiny-invariant": "1.3.3",
@@ -1,11 +1,14 @@
1
- import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
2
- import { toTagged } from '@zgeoff/std';
3
- import { P, match } from 'ts-pattern';
4
- import type { FactLog, StoredFact } from './fact-log.ts';
5
- import { pickSettlement } from './pick-settlement.ts';
1
+ import type { FactEnvelope, Logger, Result, StreamName, StreamOffset } from '@pepys/kit';
2
+ import type { FactLog } from './fact-log.ts';
3
+ import { pickAttemptStep } from './pick-attempt-step.ts';
4
+ import type { AttemptResult } from './pick-attempt-step.ts';
5
+ import { pickRetryStep } from './pick-retry-step.ts';
6
+ import { pickSettledAttempt } from './pick-settled-attempt.ts';
7
+ import { readRun } from './read-run.ts';
6
8
 
7
9
  interface BatchAppenderOptions {
8
10
  log: FactLog;
11
+ logger: Logger;
9
12
  maxAttempts: number;
10
13
  retryDelayMs: number;
11
14
  }
@@ -31,100 +34,59 @@ export function makeBatchAppender(options: BatchAppenderOptions): BatchAppender
31
34
  for (let attempt = 1; ; attempt += 1) {
32
35
  const result = await runAttempt(options, stream, facts, pendingTail);
33
36
 
34
- if (result.kind === 'stored') {
35
- return { ok: true, value: result.stored };
36
- }
37
+ const step = pickRetryStep(result, attempt, options);
37
38
 
38
- if (result.kind === 'refused') {
39
- return { ok: false, failure: result.failure };
39
+ if (step.kind === 'answer') {
40
+ return step.answer;
40
41
  }
41
42
 
42
- pendingTail = result.pendingTail;
43
-
44
- if (attempt >= options.maxAttempts) {
45
- return { ok: false, failure: { kind: 'not-stored', attempts: attempt } };
46
- }
43
+ pendingTail = step.pendingTail;
47
44
 
48
- await Bun.sleep(options.retryDelayMs * attempt);
45
+ await Bun.sleep(step.delayMs);
49
46
  }
50
47
  };
51
48
  }
52
49
 
53
- interface Stored {
54
- firstOffset: StreamOffset;
55
- count: number;
56
- }
57
-
58
- type StopFailure = Extract<AppendFailure, { kind: 'refused' }>;
59
-
60
- // What one attempt leaves: the answer, or the tail that earlier attempts with an unknown outcome
61
- // aimed at, which a later attempt can still land on. `null` when no such attempt is open.
62
- type AttemptResult =
63
- | { kind: 'stored'; stored: Stored }
64
- | { kind: 'refused'; failure: StopFailure }
65
- | { kind: 'again'; pendingTail: StreamOffset | null };
66
-
50
+ // Every attempt that is still open aimed at the same tail: a new tail comes only after the offsets
51
+ // there are known to hold another writer's records.
67
52
  async function runAttempt(
68
- options: BatchAppenderOptions,
53
+ options: Pick<BatchAppenderOptions, 'log' | 'logger'>,
69
54
  stream: StreamName,
70
55
  facts: readonly FactEnvelope<unknown>[],
71
56
  pendingTail: StreamOffset | null,
72
57
  ): Promise<AttemptResult> {
73
- // Every attempt that is still open aimed at the same tail: a new tail comes only after the
74
- // offsets there are known to hold another writer's records.
75
- const read =
58
+ const aim =
76
59
  pendingTail === null
77
- ? await readTail(options.log, stream)
78
- : { ok: true as const, value: pendingTail };
60
+ ? await readAimedTail(options, stream)
61
+ : { kind: 'aim' as const, tail: pendingTail };
79
62
 
80
- if (read === null) {
81
- return { kind: 'again', pendingTail };
63
+ if (aim.kind !== 'aim') {
64
+ return aim;
82
65
  }
83
66
 
84
- if (!read.ok) {
85
- return { kind: 'refused', failure: read.failure };
86
- }
67
+ const outcome = await options.log.appendBatch(stream, facts, aim.tail);
68
+
69
+ const step = pickAttemptStep(outcome, aim.tail, pendingTail);
87
70
 
88
- const aimed = read.value;
89
-
90
- const outcome = await options.log.appendBatch(stream, facts, aimed);
91
-
92
- return match(outcome)
93
- .with({ kind: 'acknowledged' }, (acknowledged) => ({
94
- kind: 'stored' as const,
95
- stored: { firstOffset: acknowledged.firstOffset, count: acknowledged.count },
96
- }))
97
- .with({ kind: 'rejected', reason: 'tail-mismatch' }, () =>
98
- pendingTail === null
99
- ? { kind: 'again' as const, pendingTail }
100
- : settleAttempt(options, stream, facts, aimed),
101
- )
102
- .with(
103
- { kind: 'rejected', reason: P.union('unknown-stream', 'invalid-envelope', 'too-large') },
104
- (rejected) => ({
105
- kind: 'refused' as const,
106
- failure: { kind: 'refused' as const, reason: rejected.reason },
107
- }),
108
- )
109
- .with({ kind: 'unknown' }, () => settleAttempt(options, stream, facts, aimed))
110
- .exhaustive();
71
+ return step.kind === 'settle' ? settleAttempt(options, stream, facts, step.tail) : step;
111
72
  }
112
73
 
113
- // A failed read stored nothing, so the next attempt repeats it. Returns `null` for a failed read,
114
- // and the failure for a stream the log lacks. An error the append throws is a refusal the log gives
115
- // no outcome for, such as a bad token, and a retry cannot change it.
116
- async function readTail(
117
- log: FactLog,
74
+ // A failed read stored nothing, so the next attempt repeats it. An error the append throws is a
75
+ // refusal the log gives no outcome for, such as a bad token, and a retry cannot change it.
76
+ async function readAimedTail(
77
+ options: Pick<BatchAppenderOptions, 'log' | 'logger'>,
118
78
  stream: StreamName,
119
- ): Promise<Result<StreamOffset, StopFailure> | null> {
79
+ ): Promise<{ kind: 'aim'; tail: StreamOffset } | AttemptResult> {
120
80
  try {
121
- const tail = await log.readTail(stream);
81
+ const tail = await options.log.readTail(stream);
122
82
 
123
- return tail.ok ? tail : { ok: false, failure: { kind: 'refused', reason: 'unknown-stream' } };
83
+ return tail.ok
84
+ ? { kind: 'aim', tail: tail.value }
85
+ : { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
124
86
  } catch (error: unknown) {
125
- console.error('a read before an append failed', error);
87
+ options.logger.error('a read before an append failed', error);
126
88
 
127
- return null;
89
+ return { kind: 'again', pendingTail: null };
128
90
  }
129
91
  }
130
92
 
@@ -132,67 +94,20 @@ async function readTail(
132
94
  // with the run's, in order. A search for the first ID anywhere after the tail would take another
133
95
  // writer's copy of it for the whole run.
134
96
  async function settleAttempt(
135
- options: BatchAppenderOptions,
97
+ options: Pick<BatchAppenderOptions, 'log' | 'logger'>,
136
98
  stream: StreamName,
137
99
  facts: readonly FactEnvelope<unknown>[],
138
100
  tail: StreamOffset,
139
101
  ): Promise<AttemptResult> {
140
102
  const stored = await readRun(options.log, stream, tail, facts.length).catch((error: unknown) => {
141
- console.error('a read that settles an append failed', error);
103
+ options.logger.error('a read that settles an append failed', error);
142
104
 
143
105
  return null;
144
106
  });
145
107
 
146
- if (stored === null) {
147
- return { kind: 'again', pendingTail: tail };
148
- }
149
-
150
- if (!stored.ok) {
151
- return { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
152
- }
153
-
154
- return match(
155
- pickSettlement(
156
- facts.map((fact) => fact.id),
157
- stored.value,
158
- ),
159
- )
160
- .with('landed', () => ({
161
- kind: 'stored' as const,
162
- stored: { firstOffset: tail, count: facts.length },
163
- }))
164
- .with('not-landed', () => ({ kind: 'again' as const, pendingTail: tail }))
165
- .with('taken', () => ({ kind: 'again' as const, pendingTail: null }))
166
- .exhaustive();
167
- }
168
-
169
- // A read can end before `count` records, so it repeats from the next offset until it has them all
170
- // or the stream ends.
171
- async function readRun(
172
- log: FactLog,
173
- stream: StreamName,
174
- from: StreamOffset,
175
- count: number,
176
- ): Promise<Result<readonly StoredFact[], { reason: 'unknown-stream' }>> {
177
- const records: StoredFact[] = [];
178
-
179
- while (records.length < count) {
180
- const read = await log.read(
181
- stream,
182
- toTagged<StreamOffset>(from + records.length),
183
- count - records.length,
184
- );
185
-
186
- if (!read.ok) {
187
- return read;
188
- }
189
-
190
- if (read.value.length === 0) {
191
- break;
192
- }
193
-
194
- records.push(...read.value);
195
- }
196
-
197
- return { ok: true, value: records };
108
+ return pickSettledAttempt(
109
+ stored,
110
+ facts.map((fact) => fact.id),
111
+ tail,
112
+ );
198
113
  }
@@ -1,7 +1,7 @@
1
1
  import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
2
2
  import { toTagged } from '@zgeoff/std';
3
3
  import invariant from 'tiny-invariant';
4
- import { checkAppendBatch } from '../check-append-batch.ts';
4
+ import { checkAppendBatch } from '../../utils/check-append-batch.ts';
5
5
  import type { AppendBatchOutcome, FactLog, StoredFact } from '../fact-log.ts';
6
6
  import { parseStoredFact } from '../parse-stored-fact.ts';
7
7
 
@@ -0,0 +1,53 @@
1
+ import type { StreamOffset } from '@pepys/kit';
2
+ import { P, match } from 'ts-pattern';
3
+ import type { AppendBatchOutcome } from './fact-log.ts';
4
+
5
+ export interface StoredRun {
6
+ firstOffset: StreamOffset;
7
+ count: number;
8
+ }
9
+
10
+ // a reason the log gives that no retry changes
11
+ export interface StopFailure {
12
+ kind: 'refused';
13
+ reason: 'unknown-stream' | 'invalid-envelope' | 'too-large';
14
+ }
15
+
16
+ // What one attempt leaves: the answer, or the tail that earlier attempts with an unknown outcome
17
+ // aimed at, which a later attempt can still land on. `null` when no such attempt is open.
18
+ export type AttemptResult =
19
+ | { kind: 'stored'; stored: StoredRun }
20
+ | { kind: 'refused'; failure: StopFailure }
21
+ | { kind: 'again'; pendingTail: StreamOffset | null };
22
+
23
+ // `settle`: read the offsets from `tail` to learn whether the run is there.
24
+ export type AttemptStep = AttemptResult | { kind: 'settle'; tail: StreamOffset };
25
+
26
+ // What follows an append that aimed at `aimed`. A tail mismatch with no attempt open means another
27
+ // writer moved the tail, so the next attempt reads it again. With an attempt open, the mismatch may
28
+ // be that attempt landing late, so the offsets it aimed at are read.
29
+ export function pickAttemptStep(
30
+ outcome: AppendBatchOutcome,
31
+ aimed: StreamOffset,
32
+ pendingTail: StreamOffset | null,
33
+ ): AttemptStep {
34
+ return match(outcome)
35
+ .with({ kind: 'acknowledged' }, (acknowledged) => ({
36
+ kind: 'stored' as const,
37
+ stored: { firstOffset: acknowledged.firstOffset, count: acknowledged.count },
38
+ }))
39
+ .with({ kind: 'rejected', reason: 'tail-mismatch' }, () =>
40
+ pendingTail === null
41
+ ? { kind: 'again' as const, pendingTail }
42
+ : { kind: 'settle' as const, tail: aimed },
43
+ )
44
+ .with(
45
+ { kind: 'rejected', reason: P.union('unknown-stream', 'invalid-envelope', 'too-large') },
46
+ (rejected) => ({
47
+ kind: 'refused' as const,
48
+ failure: { kind: 'refused' as const, reason: rejected.reason },
49
+ }),
50
+ )
51
+ .with({ kind: 'unknown' }, () => ({ kind: 'settle' as const, tail: aimed }))
52
+ .exhaustive();
53
+ }
@@ -0,0 +1,47 @@
1
+ import type { Result, StreamOffset } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+ import type { AttemptResult, StopFailure, StoredRun } from './pick-attempt-step.ts';
4
+
5
+ interface RetryLimits {
6
+ maxAttempts: number;
7
+ retryDelayMs: number;
8
+ }
9
+
10
+ export type RetryStep =
11
+ | {
12
+ kind: 'answer';
13
+ answer: Result<StoredRun, StopFailure | { kind: 'not-stored'; attempts: number }>;
14
+ }
15
+ | { kind: 'retry'; pendingTail: StreamOffset | null; delayMs: number };
16
+
17
+ // The wait before attempt `attempt + 1` grows by `retryDelayMs` with each attempt.
18
+ export function pickRetryStep(
19
+ result: AttemptResult,
20
+ attempt: number,
21
+ limits: RetryLimits,
22
+ ): RetryStep {
23
+ return match(result)
24
+ .with({ kind: 'stored' }, (stored) => ({
25
+ kind: 'answer' as const,
26
+ answer: { ok: true as const, value: stored.stored },
27
+ }))
28
+ .with({ kind: 'refused' }, (refused) => ({
29
+ kind: 'answer' as const,
30
+ answer: { ok: false as const, failure: refused.failure },
31
+ }))
32
+ .with({ kind: 'again' }, (again): RetryStep => {
33
+ if (attempt >= limits.maxAttempts) {
34
+ return {
35
+ kind: 'answer',
36
+ answer: { ok: false, failure: { kind: 'not-stored', attempts: attempt } },
37
+ };
38
+ }
39
+
40
+ return {
41
+ kind: 'retry',
42
+ pendingTail: again.pendingTail,
43
+ delayMs: limits.retryDelayMs * attempt,
44
+ };
45
+ })
46
+ .exhaustive();
47
+ }
@@ -0,0 +1,30 @@
1
+ import type { FactID, Result, StreamOffset } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+ import type { StoredFact } from './fact-log.ts';
4
+ import type { AttemptResult } from './pick-attempt-step.ts';
5
+ import { pickSettlement } from './pick-settlement.ts';
6
+
7
+ // `stored` is the read of the offsets `[tail, tail + n)` that an attempt aimed at, or `null` when
8
+ // the read failed, which leaves the attempt open.
9
+ export function pickSettledAttempt(
10
+ stored: Result<readonly StoredFact[], { reason: 'unknown-stream' }> | null,
11
+ ids: readonly FactID[],
12
+ tail: StreamOffset,
13
+ ): AttemptResult {
14
+ if (stored === null) {
15
+ return { kind: 'again', pendingTail: tail };
16
+ }
17
+
18
+ if (!stored.ok) {
19
+ return { kind: 'refused', failure: { kind: 'refused', reason: 'unknown-stream' } };
20
+ }
21
+
22
+ return match(pickSettlement(ids, stored.value))
23
+ .with('landed', () => ({
24
+ kind: 'stored' as const,
25
+ stored: { firstOffset: tail, count: ids.length },
26
+ }))
27
+ .with('not-landed', () => ({ kind: 'again' as const, pendingTail: tail }))
28
+ .with('taken', () => ({ kind: 'again' as const, pendingTail: null }))
29
+ .exhaustive();
30
+ }
@@ -0,0 +1,34 @@
1
+ import type { Result, StreamName, StreamOffset } from '@pepys/kit';
2
+ import { toTagged } from '@zgeoff/std';
3
+ import type { FactLog, StoredFact } from './fact-log.ts';
4
+
5
+ // A read can end before `count` records, so it repeats from the next offset until it has them all
6
+ // or the stream ends.
7
+ export async function readRun(
8
+ log: FactLog,
9
+ stream: StreamName,
10
+ from: StreamOffset,
11
+ count: number,
12
+ ): Promise<Result<readonly StoredFact[], { reason: 'unknown-stream' }>> {
13
+ const records: StoredFact[] = [];
14
+
15
+ while (records.length < count) {
16
+ const read = await log.read(
17
+ stream,
18
+ toTagged<StreamOffset>(from + records.length),
19
+ count - records.length,
20
+ );
21
+
22
+ if (!read.ok) {
23
+ return read;
24
+ }
25
+
26
+ if (read.value.length === 0) {
27
+ break;
28
+ }
29
+
30
+ records.push(...read.value);
31
+ }
32
+
33
+ return { ok: true, value: records };
34
+ }
@@ -1,4 +1,5 @@
1
- import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
1
+ import { createLogger } from '@pepys/kit';
2
+ import type { FactEnvelope, Logger, Result, StreamName, StreamOffset } from '@pepys/kit';
2
3
  import {
3
4
  AppendInput,
4
5
  AppendRecord,
@@ -8,7 +9,7 @@ import {
8
9
  } from '@s2-dev/streamstore';
9
10
  import type { S2Basin, StreamConfig } from '@s2-dev/streamstore';
10
11
  import { toTagged } from '@zgeoff/std';
11
- import { checkAppendBatch } from '../check-append-batch.ts';
12
+ import { checkAppendBatch } from '../../utils/check-append-batch.ts';
12
13
  import type { AppendBatchOutcome, FactLog, StoredFact } from '../fact-log.ts';
13
14
  import { parseStoredFact } from '../parse-stored-fact.ts';
14
15
  import { findAppendOutcome, isUnknownStream } from './find-append-outcome.ts';
@@ -18,6 +19,9 @@ interface S2FactLogParameters {
18
19
  accessToken: string;
19
20
  basin: string;
20
21
  streams: readonly StreamName[];
22
+
23
+ // defaults to a logger that writes to stderr
24
+ logger?: Logger | undefined;
21
25
  }
22
26
 
23
27
  // Creates the basin and the streams when they are missing. A stream that exists keeps its records
@@ -29,6 +33,8 @@ export async function createS2FactLog(parameters: S2FactLogParameters): Promise<
29
33
  requestTimeoutMillis: 5000,
30
34
  };
31
35
 
36
+ const logger = parameters.logger ?? createLogger(process.stderr);
37
+
32
38
  const client = new S2(options);
33
39
 
34
40
  // A client retry could store a record twice before the writer knows; the writer owns retries.
@@ -45,7 +51,7 @@ export async function createS2FactLog(parameters: S2FactLogParameters): Promise<
45
51
 
46
52
  return {
47
53
  appendBatch: (stream, facts, expectedTail) =>
48
- appendBatch(appendBasin, stream, facts, expectedTail),
54
+ appendBatch({ basin: appendBasin, logger }, stream, facts, expectedTail),
49
55
  read: (stream, from, count) => readFacts(basin, stream, from, count),
50
56
  readTail: (stream) => readTail(basin, stream),
51
57
  };
@@ -78,8 +84,13 @@ async function createInfiniteStream(basin: S2Basin, stream: StreamName): Promise
78
84
  }
79
85
  }
80
86
 
87
+ interface S2Writer {
88
+ basin: S2Basin;
89
+ logger: Logger;
90
+ }
91
+
81
92
  async function appendBatch(
82
- basin: S2Basin,
93
+ writer: S2Writer,
83
94
  stream: StreamName,
84
95
  facts: readonly FactEnvelope<unknown>[],
85
96
  expectedTail: StreamOffset,
@@ -97,7 +108,7 @@ async function appendBatch(
97
108
  );
98
109
 
99
110
  try {
100
- const ack = await basin.stream(stream).append(input);
111
+ const ack = await writer.basin.stream(stream).append(input);
101
112
 
102
113
  return {
103
114
  kind: 'acknowledged',
@@ -112,7 +123,7 @@ async function appendBatch(
112
123
  }
113
124
 
114
125
  if (outcome.kind === 'unknown') {
115
- console.error('an s2 append has an unknown outcome', error);
126
+ writer.logger.error('an s2 append has an unknown outcome', error);
116
127
  }
117
128
 
118
129
  return outcome;
package/src/index.ts CHANGED
@@ -2,15 +2,16 @@ export type * from './blob-store/blob-store.ts';
2
2
  export { createGarageBlobStore } from './blob-store/garage/create-garage-blob-store.ts';
3
3
  export { createLocalFolderBlobStore } from './blob-store/local-folder/create-local-folder-blob-store.ts';
4
4
  export type * from './fact-log/fact-log.ts';
5
- export { appendLimits } from './fact-log/check-append-batch.ts';
5
+ export { appendLimits } from './utils/check-append-batch.ts';
6
6
  export { makeBatchAppender } from './fact-log/make-batch-appender.ts';
7
7
  export type { AppendFailure, BatchAppender } from './fact-log/make-batch-appender.ts';
8
8
  export { createMemoryFactLog } from './fact-log/memory/create-memory-fact-log.ts';
9
9
  export type { MemoryFactLog } from './fact-log/memory/create-memory-fact-log.ts';
10
- export { planAppends } from './fact-log/plan-appends.ts';
10
+ export { planAppends } from './utils/plan-appends.ts';
11
11
  export { createS2FactLog } from './fact-log/s2/create-s2-fact-log.ts';
12
12
  export { createIngest } from './receive/create-ingest.ts';
13
13
  export type { Ingest, IngestOptions } from './receive/create-ingest.ts';
14
+ export { cursorStream } from './pull/cursors/cursor-stream.ts';
14
15
  export type { PullPathName, PullRun, PullRunner } from './pull/make-pull-runner.ts';
15
16
  export type { ResyncMarks } from './receive/resync-marks/create-resync-marks.ts';
16
17
  export { createFakeSenderProof } from './sender-proof/fake/create-fake-sender-proof.ts';
@@ -20,4 +21,3 @@ export { readImpIdentityTrust } from './sender-proof/imp-identity/read-imp-ident
20
21
  export type { ImpIdentityTrust } from './sender-proof/imp-identity/types.ts';
21
22
  export { noSenderProof } from './sender-proof/none/no-sender-proof.ts';
22
23
  export { serveIngest } from './serve/serve-ingest.ts';
23
- export type * from './source-port/types.ts';
@@ -0,0 +1,23 @@
1
+ import type { SourceDefinition } from '@pepys/kit';
2
+ import type { PullPathName } from './make-pull-runner.ts';
3
+
4
+ export interface TimedPull {
5
+ source: string;
6
+ path: PullPathName;
7
+ intervalMs: number;
8
+ }
9
+
10
+ const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
11
+
12
+ // Every pull path that sets an interval, in the order of the sources and then of the slots.
13
+ export function collectTimedPulls(sources: readonly SourceDefinition[]): TimedPull[] {
14
+ return sources.flatMap((source) =>
15
+ pullPathNames.flatMap((pathName) => {
16
+ const path = source[pathName];
17
+
18
+ return path?.kind === 'pull' && path.intervalMs !== null
19
+ ? [{ source: source.name, path: pathName, intervalMs: path.intervalMs }]
20
+ : [];
21
+ }),
22
+ );
23
+ }
@@ -0,0 +1,8 @@
1
+ import type { EntityID } from '@pepys/kit';
2
+ import { toTagged } from '@zgeoff/std';
3
+ import type { PullPathName } from '../make-pull-runner.ts';
4
+
5
+ // The entity of a pull path's cursor records, such as `github:pull_path:recovery`.
6
+ export function buildCursorEntity(source: string, path: PullPathName): EntityID {
7
+ return toTagged<EntityID>(`${source}:pull_path:${path}`);
8
+ }
@@ -0,0 +1,32 @@
1
+ import { buildObservationFactID } from '@pepys/kit';
2
+ import type { FactEnvelope } from '@pepys/kit';
3
+ import type { PullPathName } from '../make-pull-runner.ts';
4
+ import { buildCursorEntity } from './build-cursor-entity.ts';
5
+
6
+ interface CursorPosition {
7
+ source: string;
8
+ path: PullPathName;
9
+ cursor: string;
10
+ nowMs: number;
11
+ }
12
+
13
+ // The record of one pull path's position, in the envelope of a fact so that the log takes it. The
14
+ // ID comes from the position alone, so a retry of the append is settled as the same record.
15
+ export function buildCursorRecord(position: CursorPosition): FactEnvelope<Record<string, never>> {
16
+ const entity = buildCursorEntity(position.source, position.path);
17
+
18
+ return {
19
+ id: buildObservationFactID({
20
+ source: position.source,
21
+ entity,
22
+ sourceUpdateTime: position.cursor,
23
+ }),
24
+ type: 'ingest.cursor.saved',
25
+ v: 1,
26
+ api: 'ingest/1',
27
+ entity,
28
+ at: new Date(position.nowMs).toISOString(),
29
+ cursor: position.cursor,
30
+ payload: {},
31
+ };
32
+ }
@@ -0,0 +1,6 @@
1
+ import type { StreamName } from '@pepys/kit';
2
+ import { toTagged } from '@zgeoff/std';
3
+
4
+ // The one stream that belongs to no fact family. Its name has two parts, where a family's stream
5
+ // has three, so no family and no consumer can name it.
6
+ export const cursorStream = toTagged<StreamName>('facts.cursors');
@@ -0,0 +1,13 @@
1
+ import type { ResumedPull } from './read-saved-cursors.ts';
2
+
3
+ // The start line of one pull timer. It says only whether a cursor was saved: a source chooses what
4
+ // its cursor holds, so the value stays out of the output.
5
+ export function formatPullStart(pull: ResumedPull): string {
6
+ const detail = {
7
+ source: pull.source,
8
+ path: pull.path,
9
+ cursor: pull.cursor === null ? 'none' : 'saved',
10
+ };
11
+
12
+ return `pull ${pull.source} ${pull.path} ${JSON.stringify(detail)}`;
13
+ }
@@ -0,0 +1,55 @@
1
+ import type { EntityID, StreamOffset } from '@pepys/kit';
2
+ import { toTagged } from '@zgeoff/std';
3
+ import invariant from 'tiny-invariant';
4
+ import type { FactLog } from '../../fact-log/fact-log.ts';
5
+ import { readRun } from '../../fact-log/read-run.ts';
6
+ import type { TimedPull } from '../collect-timed-pulls.ts';
7
+ import { buildCursorEntity } from './build-cursor-entity.ts';
8
+ import { cursorStream } from './cursor-stream.ts';
9
+
10
+ export interface ResumedPull extends TimedPull {
11
+ // the last cursor that the log holds for the pull path, or null when it holds none
12
+ cursor: string | null;
13
+ }
14
+
15
+ const pageSize = 1000;
16
+
17
+ // Gives each pull the cursor of its newest record in the cursor stream. Reads the stream from its
18
+ // tail towards its start, one page at a time, and stops once every pull has its cursor.
19
+ export async function readSavedCursors(
20
+ log: FactLog,
21
+ pulls: readonly TimedPull[],
22
+ ): Promise<ResumedPull[]> {
23
+ const tail = await log.readTail(cursorStream);
24
+
25
+ invariant(tail.ok, 'the log holds the cursor stream');
26
+
27
+ const wanted = new Set(pulls.map((pull) => buildCursorEntity(pull.source, pull.path)));
28
+ const saved = new Map<EntityID, string>();
29
+
30
+ let end: number = tail.value;
31
+
32
+ while (end > 0 && saved.size < wanted.size) {
33
+ const from = Math.max(0, end - pageSize);
34
+
35
+ const page = await readRun(log, cursorStream, toTagged<StreamOffset>(from), end - from);
36
+
37
+ invariant(page.ok, 'the log holds the cursor stream');
38
+
39
+ for (const stored of page.value.toReversed()) {
40
+ const entity = stored.fact.entity;
41
+ const cursor = stored.fact.cursor;
42
+
43
+ if (cursor !== undefined && wanted.has(entity) && !saved.has(entity)) {
44
+ saved.set(entity, cursor);
45
+ }
46
+ }
47
+
48
+ end = from;
49
+ }
50
+
51
+ return pulls.map((pull) => ({
52
+ ...pull,
53
+ cursor: saved.get(buildCursorEntity(pull.source, pull.path)) ?? null,
54
+ }));
55
+ }