@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 +4 -4
- package/src/fact-log/make-batch-appender.ts +2 -33
- package/src/fact-log/memory/create-memory-fact-log.ts +1 -1
- package/src/fact-log/read-run.ts +34 -0
- package/src/fact-log/s2/create-s2-fact-log.ts +1 -1
- package/src/index.ts +3 -2
- package/src/pull/collect-timed-pulls.ts +23 -0
- package/src/pull/cursors/build-cursor-entity.ts +8 -0
- package/src/pull/cursors/build-cursor-record.ts +32 -0
- package/src/pull/cursors/cursor-stream.ts +6 -0
- package/src/pull/cursors/format-pull-start.ts +13 -0
- package/src/pull/cursors/read-saved-cursors.ts +55 -0
- package/src/pull/make-pull-runner.ts +94 -44
- package/src/pull/with-source-lock.ts +76 -0
- package/src/receive/answers/build-failure-answer.ts +2 -1
- package/src/receive/pick-received-step.ts +2 -2
- package/src/receive/write-batch.ts +1 -1
- package/src/serve/serve-ingest.ts +12 -2
- package/src/serve/start-pull-timers.ts +37 -48
- package/src/{receive → utils}/plan-batch.ts +1 -1
- /package/src/{fact-log → utils}/check-append-batch.ts +0 -0
- /package/src/{receive → utils}/find-batch-problem.ts +0 -0
- /package/src/{fact-log → utils}/parse-stored-body.ts +0 -0
- /package/src/{receive → utils}/pick-batch-plan.ts +0 -0
- /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.4.
|
|
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": "
|
|
25
|
-
"@pepys/kit": "0.4.
|
|
26
|
-
"@pepys/schema": "0.4.
|
|
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 {
|
|
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 '
|
|
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 '
|
|
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 './
|
|
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 './
|
|
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
|
|
11
|
-
// failed, so the next run starts from the same cursor. Any other
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
|
53
|
-
|
|
59
|
+
const run = await withSourceLock(locks, source.name, signal, () =>
|
|
60
|
+
runPull(dependencies, { source, path, pathName, cursor, signal }),
|
|
61
|
+
);
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
72
|
+
return path?.kind === 'pull' ? path : undefined;
|
|
73
|
+
}
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
interface PullRequest {
|
|
76
|
+
source: SourceDefinition;
|
|
77
|
+
path: PullPath;
|
|
78
|
+
pathName: PullPathName;
|
|
79
|
+
cursor: string | null;
|
|
80
|
+
signal: AbortSignal;
|
|
81
|
+
}
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
133
|
+
const saved = await writeBatch(new Map([[cursorStream, [[record]]]]), dependencies);
|
|
78
134
|
|
|
79
|
-
if (
|
|
80
|
-
return
|
|
135
|
+
if (saved !== 'stored') {
|
|
136
|
+
return saved === 'not-stored'
|
|
81
137
|
? { kind: 'not-stored' }
|
|
82
|
-
: { kind: 'refused', reason: 'the log refused the
|
|
138
|
+
: { kind: 'refused', reason: 'the log refused the cursor record' };
|
|
83
139
|
}
|
|
140
|
+
}
|
|
84
141
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
142
|
+
for (const mark of marks) {
|
|
143
|
+
dependencies.resyncMarks.remove(source.name, mark);
|
|
144
|
+
}
|
|
88
145
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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"
|
|
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 '
|
|
4
|
-
import type { BatchPlan } from '
|
|
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 '
|
|
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
|
|
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
|
|
1
|
+
import type { Logger } from '@pepys/kit';
|
|
2
2
|
import { match } from 'ts-pattern';
|
|
3
|
-
import type {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
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
|
-
|
|
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 =
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const timers = pulls.map((pull) => {
|
|
18
|
+
let cursor = pull.cursor;
|
|
19
|
+
let isRunning = false;
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const runOnce = async (): Promise<void> => {
|
|
22
|
+
if (isRunning || controller.signal.aborted) {
|
|
23
|
+
return;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
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
|
-
|
|
28
|
+
const ended = Promise.withResolvers<void>();
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
activeRuns.add(ended.promise);
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
try {
|
|
33
|
+
const run = await runPull(pull.source, pull.path, cursor, controller.signal);
|
|
42
34
|
|
|
43
|
-
|
|
35
|
+
cursor = pickNextCursor(run, cursor);
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 '
|
|
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
|