@pepys/ingest 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/ingest",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "pepys ingest: the receive routes, the fact log and the blob store",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,9 +21,9 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@impz/client": "6.12.0",
25
- "@pepys/kit": "0.4.0",
26
- "@pepys/schema": "0.4.0",
24
+ "@impz/client": "7.0.0",
25
+ "@pepys/kit": "0.4.2",
26
+ "@pepys/schema": "0.4.2",
27
27
  "@s2-dev/streamstore": "0.30.0",
28
28
  "@zgeoff/std": "0.4.3",
29
29
  "tiny-invariant": "1.3.3",
@@ -1,10 +1,10 @@
1
1
  import type { FactEnvelope, Logger, Result, StreamName, StreamOffset } from '@pepys/kit';
2
- import { toTagged } from '@zgeoff/std';
3
- import type { FactLog, StoredFact } from './fact-log.ts';
2
+ import type { FactLog } from './fact-log.ts';
4
3
  import { pickAttemptStep } from './pick-attempt-step.ts';
5
4
  import type { AttemptResult } from './pick-attempt-step.ts';
6
5
  import { pickRetryStep } from './pick-retry-step.ts';
7
6
  import { pickSettledAttempt } from './pick-settled-attempt.ts';
7
+ import { readRun } from './read-run.ts';
8
8
 
9
9
  interface BatchAppenderOptions {
10
10
  log: FactLog;
@@ -111,34 +111,3 @@ async function settleAttempt(
111
111
  tail,
112
112
  );
113
113
  }
114
-
115
- // A read can end before `count` records, so it repeats from the next offset until it has them all
116
- // or the stream ends.
117
- async function readRun(
118
- log: FactLog,
119
- stream: StreamName,
120
- from: StreamOffset,
121
- count: number,
122
- ): Promise<Result<readonly StoredFact[], { reason: 'unknown-stream' }>> {
123
- const records: StoredFact[] = [];
124
-
125
- while (records.length < count) {
126
- const read = await log.read(
127
- stream,
128
- toTagged<StreamOffset>(from + records.length),
129
- count - records.length,
130
- );
131
-
132
- if (!read.ok) {
133
- return read;
134
- }
135
-
136
- if (read.value.length === 0) {
137
- break;
138
- }
139
-
140
- records.push(...read.value);
141
- }
142
-
143
- return { ok: true, value: records };
144
- }
@@ -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,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
+ }
@@ -9,7 +9,7 @@ import {
9
9
  } from '@s2-dev/streamstore';
10
10
  import type { S2Basin, StreamConfig } from '@s2-dev/streamstore';
11
11
  import { toTagged } from '@zgeoff/std';
12
- import { checkAppendBatch } from '../check-append-batch.ts';
12
+ import { checkAppendBatch } from '../../utils/check-append-batch.ts';
13
13
  import type { AppendBatchOutcome, FactLog, StoredFact } from '../fact-log.ts';
14
14
  import { parseStoredFact } from '../parse-stored-fact.ts';
15
15
  import { findAppendOutcome, isUnknownStream } from './find-append-outcome.ts';
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';
@@ -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
+ }
@@ -1,14 +1,19 @@
1
1
  import type { CheckedDeployment, Logger, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
2
2
  import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
3
- import { pickBatchPlan } from '../receive/pick-batch-plan.ts';
4
3
  import type { ResyncMarks } from '../receive/resync-marks/create-resync-marks.ts';
5
4
  import { writeBatch } from '../receive/write-batch.ts';
6
5
  import type { StreamTurns } from '../receive/write-batch.ts';
6
+ import { pickBatchPlan } from '../utils/pick-batch-plan.ts';
7
+ import { buildCursorRecord } from './cursors/build-cursor-record.ts';
8
+ import { cursorStream } from './cursors/cursor-stream.ts';
9
+ import { withSourceLock } from './with-source-lock.ts';
10
+ import type { SourceLocks } from './with-source-lock.ts';
7
11
 
8
12
  export type PullPathName = 'live' | 'recovery' | 'backfill' | 'resync';
9
13
 
10
- // `stored`: the batch is in the log, and the next run starts from `cursor`. `not-stored`: an append
11
- // failed, so the next run starts from the same cursor. Any other answer is the pull's own.
14
+ // `stored`: the batch and then the cursor record are in the log, and the next run starts from
15
+ // `cursor`. `not-stored`: an append failed, so the next run starts from the same cursor. Any other
16
+ // answer is the pull's own.
12
17
  export type PullRun =
13
18
  | { kind: 'stored'; stored: number; cursor: string; isDone: boolean }
14
19
  | { kind: 'not-stored' }
@@ -34,10 +39,12 @@ interface PullRunnerDependencies {
34
39
  logger: Logger;
35
40
  }
36
41
 
37
- // For a resync pull, removes each mark it was given that no tie set again since. A pull's fact is
38
- // noted in no mark: a resynced fact ties with the body it replaces, and would mark its entity again
39
- // on every run.
42
+ // The pull paths of one source run one after the other, in the order they were asked for, and the
43
+ // pull paths of two sources run at the same time. A run that waits for its source ends as refused
44
+ // when its signal aborts, and its pull never starts.
40
45
  export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner {
46
+ const locks: SourceLocks = new Map();
47
+
41
48
  return async (sourceName, pathName, cursor, signal) => {
42
49
  const source = dependencies.deployment.sources.find(
43
50
  (candidate) => candidate.name === sourceName,
@@ -49,54 +56,97 @@ export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner
49
56
  return { kind: 'refused', reason: `the source ${sourceName} has no ${pathName} pull` };
50
57
  }
51
58
 
52
- const marks = pathName === 'resync' ? dependencies.resyncMarks.list(source.name) : [];
53
- const env: Record<string, string> = {};
59
+ const run = await withSourceLock(locks, source.name, signal, () =>
60
+ runPull(dependencies, { source, path, pathName, cursor, signal }),
61
+ );
54
62
 
55
- for (const name of path.env ?? []) {
56
- env[name] = dependencies.secrets[name] ?? '';
57
- }
63
+ return run === 'stopped'
64
+ ? { kind: 'refused', reason: `the ${pathName} pull of ${sourceName} stopped before its turn` }
65
+ : run;
66
+ };
67
+ }
58
68
 
59
- const pulled = await path.pull(cursor, {
60
- env,
61
- marks,
62
- now: dependencies.now,
63
- log: dependencies.logger,
64
- signal,
65
- });
69
+ function findPullPath(source: SourceDefinition, pathName: PullPathName): PullPath | undefined {
70
+ const path = source[pathName];
66
71
 
67
- if (pulled.kind !== 'batch') {
68
- return pulled;
69
- }
72
+ return path?.kind === 'pull' ? path : undefined;
73
+ }
70
74
 
71
- const plan = pickBatchPlan(source, pulled.batch);
75
+ interface PullRequest {
76
+ source: SourceDefinition;
77
+ path: PullPath;
78
+ pathName: PullPathName;
79
+ cursor: string | null;
80
+ signal: AbortSignal;
81
+ }
72
82
 
73
- if (!plan.ok) {
74
- return { kind: 'refused', reason: plan.failure.reason };
75
- }
83
+ // For a resync pull, removes each mark it was given that no tie set again since. A pull's fact is
84
+ // noted in no mark: a resynced fact ties with the body it replaces, and would mark its entity again
85
+ // on every run.
86
+ async function runPull(
87
+ dependencies: PullRunnerDependencies,
88
+ request: PullRequest,
89
+ ): Promise<PullRun> {
90
+ const source = request.source;
91
+ const marks = request.pathName === 'resync' ? dependencies.resyncMarks.list(source.name) : [];
92
+ const env: Record<string, string> = {};
93
+
94
+ for (const name of request.path.env ?? []) {
95
+ env[name] = dependencies.secrets[name] ?? '';
96
+ }
97
+
98
+ const pulled = await request.path.pull(request.cursor, {
99
+ env,
100
+ marks,
101
+ now: dependencies.now,
102
+ log: dependencies.logger,
103
+ signal: request.signal,
104
+ });
105
+
106
+ if (pulled.kind !== 'batch') {
107
+ return pulled;
108
+ }
109
+
110
+ const plan = pickBatchPlan(source, pulled.batch);
111
+
112
+ if (!plan.ok) {
113
+ return { kind: 'refused', reason: plan.failure.reason };
114
+ }
115
+
116
+ const outcome = await writeBatch(plan.value, dependencies);
117
+
118
+ if (outcome !== 'stored') {
119
+ return outcome === 'not-stored'
120
+ ? { kind: 'not-stored' }
121
+ : { kind: 'refused', reason: 'the log refused the batch' };
122
+ }
123
+
124
+ // A run that leaves the cursor where it was has nothing to save.
125
+ if (pulled.cursor !== request.cursor) {
126
+ const record = buildCursorRecord({
127
+ source: source.name,
128
+ path: request.pathName,
129
+ cursor: pulled.cursor,
130
+ nowMs: dependencies.now(),
131
+ });
76
132
 
77
- const outcome = await writeBatch(plan.value, dependencies);
133
+ const saved = await writeBatch(new Map([[cursorStream, [[record]]]]), dependencies);
78
134
 
79
- if (outcome !== 'stored') {
80
- return outcome === 'not-stored'
135
+ if (saved !== 'stored') {
136
+ return saved === 'not-stored'
81
137
  ? { kind: 'not-stored' }
82
- : { kind: 'refused', reason: 'the log refused the batch' };
138
+ : { kind: 'refused', reason: 'the log refused the cursor record' };
83
139
  }
140
+ }
84
141
 
85
- for (const mark of marks) {
86
- dependencies.resyncMarks.remove(source.name, mark);
87
- }
142
+ for (const mark of marks) {
143
+ dependencies.resyncMarks.remove(source.name, mark);
144
+ }
88
145
 
89
- return {
90
- kind: 'stored',
91
- stored: pulled.batch.facts.length,
92
- cursor: pulled.cursor,
93
- isDone: pulled.isDone,
94
- };
146
+ return {
147
+ kind: 'stored',
148
+ stored: pulled.batch.facts.length,
149
+ cursor: pulled.cursor,
150
+ isDone: pulled.isDone,
95
151
  };
96
152
  }
97
-
98
- function findPullPath(source: SourceDefinition, pathName: PullPathName): PullPath | undefined {
99
- const path = source[pathName];
100
-
101
- return path?.kind === 'pull' ? path : undefined;
102
- }
@@ -0,0 +1,76 @@
1
+ // One chain of turns per source, for the whole process: the promise that settles when every run
2
+ // that asked for the source's lock so far has ended.
3
+ export type SourceLocks = Map<string, Promise<void>>;
4
+
5
+ // Runs `work` once every earlier run of the source has ended, so the runs of one source go one
6
+ // after the other in the order they asked. Returns `stopped`, and never calls `work`, when the
7
+ // signal aborts before the turn comes.
8
+ export async function withSourceLock<Value>(
9
+ locks: SourceLocks,
10
+ source: string,
11
+ signal: AbortSignal,
12
+ work: () => Promise<Value>,
13
+ ): Promise<Value | 'stopped'> {
14
+ const previous = locks.get(source) ?? Promise.resolve();
15
+ const done = Promise.withResolvers<void>();
16
+
17
+ // A run that stops while it waits ends early, and the runs behind it still wait for the runs
18
+ // ahead of it.
19
+ const turn = waitForBoth(previous, done.promise);
20
+
21
+ locks.set(source, turn);
22
+
23
+ try {
24
+ const waited = await waitForTurn(previous, signal);
25
+
26
+ return waited === 'stopped' ? 'stopped' : await work();
27
+ } finally {
28
+ done.resolve();
29
+
30
+ // A run that stops while it waits ends before the runs ahead of it, so the entry stays until
31
+ // those runs end too.
32
+ void removeEndedTurn(locks, source, turn);
33
+ }
34
+ }
35
+
36
+ async function waitForBoth(first: Promise<void>, second: Promise<void>): Promise<void> {
37
+ await first;
38
+ await second;
39
+ }
40
+
41
+ async function waitForTurn(
42
+ previous: Promise<void>,
43
+ signal: AbortSignal,
44
+ ): Promise<'turn' | 'stopped'> {
45
+ if (signal.aborted) {
46
+ return 'stopped';
47
+ }
48
+
49
+ const stopped = Promise.withResolvers<void>();
50
+
51
+ const stop = (): void => {
52
+ stopped.resolve();
53
+ };
54
+
55
+ signal.addEventListener('abort', stop);
56
+
57
+ try {
58
+ await Promise.race([previous, stopped.promise]);
59
+
60
+ return signal.aborted ? 'stopped' : 'turn';
61
+ } finally {
62
+ signal.removeEventListener('abort', stop);
63
+ }
64
+ }
65
+
66
+ async function removeEndedTurn(
67
+ locks: SourceLocks,
68
+ source: string,
69
+ turn: Promise<void>,
70
+ ): Promise<void> {
71
+ await turn;
72
+
73
+ if (locks.get(source) === turn) {
74
+ locks.delete(source);
75
+ }
76
+ }
@@ -3,7 +3,8 @@ export function buildFailureAnswer(status: number, reason: string): Response {
3
3
  return Response.json({ error: reason }, { status });
4
4
  }
5
5
 
6
- // 503 means "send the same delivery again, unchanged": every sender of v1 does that.
6
+ // 503 means "send the same delivery again, unchanged". A sender that never sends a batch twice,
7
+ // such as Codex's exporter, loses the batch.
7
8
  export function buildRetryAnswer(): Response {
8
9
  return Response.json(
9
10
  { error: 'the batch is not stored, send it again' },
@@ -1,7 +1,7 @@
1
1
  import type { PendingFact, Received, SourceContext } from '@pepys/kit';
2
2
  import { match } from 'ts-pattern';
3
- import { pickBatchPlan } from './pick-batch-plan.ts';
4
- import type { BatchPlan } from './plan-batch.ts';
3
+ import { pickBatchPlan } from '../utils/pick-batch-plan.ts';
4
+ import type { BatchPlan } from '../utils/plan-batch.ts';
5
5
 
6
6
  // `has-blob`: the source read a fact with a blob, a fault of the deployment that ingest answers 500.
7
7
  export type ReceivedStep =
@@ -1,6 +1,6 @@
1
1
  import type { Logger, StreamName } from '@pepys/kit';
2
2
  import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
3
- import type { BatchPlan } from './plan-batch.ts';
3
+ import type { BatchPlan } from '../utils/plan-batch.ts';
4
4
 
5
5
  export type BatchOutcome = 'stored' | 'refused' | 'not-stored';
6
6
 
@@ -4,6 +4,10 @@ import { makeShutdownHandler } from '@pepys/schema';
4
4
  import { toTagged } from '@zgeoff/std';
5
5
  import { readIngestConfig } from '../config/read-ingest-config.ts';
6
6
  import { createS2FactLog } from '../fact-log/s2/create-s2-fact-log.ts';
7
+ import { collectTimedPulls } from '../pull/collect-timed-pulls.ts';
8
+ import { cursorStream } from '../pull/cursors/cursor-stream.ts';
9
+ import { formatPullStart } from '../pull/cursors/format-pull-start.ts';
10
+ import { readSavedCursors } from '../pull/cursors/read-saved-cursors.ts';
7
11
  import { createIngest } from '../receive/create-ingest.ts';
8
12
  import { readImpIdentitySenderProof } from '../sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
9
13
  import { startPullTimers } from './start-pull-timers.ts';
@@ -51,10 +55,12 @@ export async function serveIngest(options: ServeIngestOptions): Promise<() => Pr
51
55
  // The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
52
56
  const log = await createS2FactLog({
53
57
  ...config.value.s2,
54
- streams: collectStreams(sources),
58
+ streams: [...collectStreams(sources), cursorStream],
55
59
  logger,
56
60
  });
57
61
 
62
+ const pulls = await readSavedCursors(log, collectTimedPulls(sources));
63
+
58
64
  const ingest = createIngest({
59
65
  deployment: checked.value,
60
66
  secrets: config.value.secrets,
@@ -76,7 +82,11 @@ export async function serveIngest(options: ServeIngestOptions): Promise<() => Pr
76
82
  fetch: ingest.handleRequest,
77
83
  });
78
84
 
79
- const stopPulls = startPullTimers(sources, ingest.runPull, logger);
85
+ for (const pull of pulls) {
86
+ console.log(formatPullStart(pull));
87
+ }
88
+
89
+ const stopPulls = startPullTimers(pulls, ingest.runPull, logger);
80
90
 
81
91
  console.log(`ingest listening on ${server.hostname}:${server.port}`);
82
92
 
@@ -1,70 +1,59 @@
1
- import type { Logger, SourceDefinition } from '@pepys/kit';
1
+ import type { Logger } from '@pepys/kit';
2
2
  import { match } from 'ts-pattern';
3
- import type { PullPathName, PullRun, PullRunner } from '../pull/make-pull-runner.ts';
3
+ import type { ResumedPull } from '../pull/cursors/read-saved-cursors.ts';
4
+ import type { PullRun, PullRunner } from '../pull/make-pull-runner.ts';
4
5
 
5
- const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
6
-
7
- // One timer per pull path with an interval. The cursor lives in memory, so a restart starts each
8
- // pull from no cursor. A run never overlaps the one before it. Returns the function that stops them:
9
- // it aborts each run in flight and settles once every run has ended.
6
+ // One timer per pull, whose first run starts from the pull's saved cursor. A timer starts no run
7
+ // while its last one waits for its source or runs. Returns the function that stops the timers: it
8
+ // aborts each run, waiting or in flight, and settles once every run has ended.
10
9
  export function startPullTimers(
11
- sources: readonly SourceDefinition[],
10
+ pulls: readonly ResumedPull[],
12
11
  runPull: PullRunner,
13
12
  logger: Logger,
14
13
  ): () => Promise<void> {
15
14
  const controller = new AbortController();
16
15
  const activeRuns = new Set<Promise<void>>();
17
16
 
18
- const timers = sources.flatMap((source) =>
19
- pullPathNames.flatMap((pathName) => {
20
- const path = source[pathName];
17
+ const timers = pulls.map((pull) => {
18
+ let cursor = pull.cursor;
19
+ let isRunning = false;
21
20
 
22
- if (path?.kind !== 'pull' || path.intervalMs === null) {
23
- return [];
21
+ const runOnce = async (): Promise<void> => {
22
+ if (isRunning || controller.signal.aborted) {
23
+ return;
24
24
  }
25
25
 
26
- let cursor: string | null = null;
27
- let isRunning = false;
28
-
29
- const runOnce = async (): Promise<void> => {
30
- if (isRunning || controller.signal.aborted) {
31
- return;
32
- }
33
-
34
- isRunning = true;
26
+ isRunning = true;
35
27
 
36
- const ended = Promise.withResolvers<void>();
28
+ const ended = Promise.withResolvers<void>();
37
29
 
38
- activeRuns.add(ended.promise);
30
+ activeRuns.add(ended.promise);
39
31
 
40
- try {
41
- const run = await runPull(source.name, pathName, cursor, controller.signal);
32
+ try {
33
+ const run = await runPull(pull.source, pull.path, cursor, controller.signal);
42
34
 
43
- cursor = pickNextCursor(run, cursor);
35
+ cursor = pickNextCursor(run, cursor);
44
36
 
45
- // a run that the stop cut short is no failure
46
- if (run.kind !== 'stored' && !controller.signal.aborted) {
47
- logger.error(`the ${pathName} pull of ${source.name} did not store`, run);
48
- }
49
- } catch (error: unknown) {
50
- if (!controller.signal.aborted) {
51
- logger.error(`the ${pathName} pull of ${source.name} threw`, error);
52
- }
53
- } finally {
54
- isRunning = false;
55
-
56
- activeRuns.delete(ended.promise);
57
- ended.resolve();
37
+ // a run that the stop cut short is no failure
38
+ if (run.kind !== 'stored' && !controller.signal.aborted) {
39
+ logger.error(`the ${pull.path} pull of ${pull.source} did not store`, run);
40
+ }
41
+ } catch (error: unknown) {
42
+ if (!controller.signal.aborted) {
43
+ logger.error(`the ${pull.path} pull of ${pull.source} threw`, error);
58
44
  }
59
- };
45
+ } finally {
46
+ isRunning = false;
47
+
48
+ activeRuns.delete(ended.promise);
49
+ ended.resolve();
50
+ }
51
+ };
60
52
 
61
- return [
62
- setInterval(() => {
63
- void runOnce();
64
- }, path.intervalMs),
65
- ];
66
- }),
67
- );
53
+ return setInterval(() => {
54
+ void runOnce();
55
+ }, pull.intervalMs);
56
+ });
68
57
 
69
58
  return async () => {
70
59
  for (const timer of timers) {
@@ -1,5 +1,5 @@
1
1
  import type { Batch, FactEnvelope, Result, StreamName } from '@pepys/kit';
2
- import { planAppends } from '../fact-log/plan-appends.ts';
2
+ import { planAppends } from './plan-appends.ts';
3
3
 
4
4
  export type BatchPlan = ReadonlyMap<StreamName, readonly (readonly FactEnvelope<unknown>[])[]>;
5
5
 
File without changes
File without changes
File without changes
File without changes
File without changes