@pepys/ingest 0.3.1 → 0.4.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/package.json +3 -3
- package/src/fact-log/make-batch-appender.ts +41 -95
- package/src/fact-log/pick-attempt-step.ts +53 -0
- package/src/fact-log/pick-retry-step.ts +47 -0
- package/src/fact-log/pick-settled-attempt.ts +30 -0
- package/src/fact-log/s2/create-s2-fact-log.ts +16 -5
- package/src/index.ts +0 -1
- package/src/pull/make-pull-runner.ts +14 -17
- package/src/receive/create-ingest.ts +10 -3
- package/src/receive/find-batch-problem.ts +4 -1
- package/src/receive/make-receive-handler.ts +21 -37
- package/src/receive/pick-batch-plan.ts +37 -0
- package/src/receive/pick-received-step.ts +56 -0
- package/src/receive/write-batch.ts +7 -5
- package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +8 -2
- package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +5 -1
- package/src/serve/serve-ingest.ts +33 -14
- package/src/serve/start-pull-timers.ts +28 -9
- package/src/source-port/types.ts +0 -64
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/ingest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
26
|
-
"@pepys/schema": "0.
|
|
25
|
+
"@pepys/kit": "0.4.0",
|
|
26
|
+
"@pepys/schema": "0.4.0",
|
|
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';
|
|
1
|
+
import type { FactEnvelope, Logger, Result, StreamName, StreamOffset } from '@pepys/kit';
|
|
2
2
|
import { toTagged } from '@zgeoff/std';
|
|
3
|
-
import { P, match } from 'ts-pattern';
|
|
4
3
|
import type { FactLog, StoredFact } from './fact-log.ts';
|
|
5
|
-
import {
|
|
4
|
+
import { pickAttemptStep } from './pick-attempt-step.ts';
|
|
5
|
+
import type { AttemptResult } from './pick-attempt-step.ts';
|
|
6
|
+
import { pickRetryStep } from './pick-retry-step.ts';
|
|
7
|
+
import { pickSettledAttempt } from './pick-settled-attempt.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
|
-
|
|
35
|
-
return { ok: true, value: result.stored };
|
|
36
|
-
}
|
|
37
|
+
const step = pickRetryStep(result, attempt, options);
|
|
37
38
|
|
|
38
|
-
if (
|
|
39
|
-
return
|
|
39
|
+
if (step.kind === 'answer') {
|
|
40
|
+
return step.answer;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
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(
|
|
45
|
+
await Bun.sleep(step.delayMs);
|
|
49
46
|
}
|
|
50
47
|
};
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
74
|
-
// offsets there are known to hold another writer's records.
|
|
75
|
-
const read =
|
|
58
|
+
const aim =
|
|
76
59
|
pendingTail === null
|
|
77
|
-
? await
|
|
78
|
-
: {
|
|
60
|
+
? await readAimedTail(options, stream)
|
|
61
|
+
: { kind: 'aim' as const, tail: pendingTail };
|
|
79
62
|
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
63
|
+
if (aim.kind !== 'aim') {
|
|
64
|
+
return aim;
|
|
82
65
|
}
|
|
83
66
|
|
|
84
|
-
|
|
85
|
-
return { kind: 'refused', failure: read.failure };
|
|
86
|
-
}
|
|
67
|
+
const outcome = await options.log.appendBatch(stream, facts, aim.tail);
|
|
87
68
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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();
|
|
69
|
+
const step = pickAttemptStep(outcome, aim.tail, pendingTail);
|
|
70
|
+
|
|
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.
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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,38 +94,22 @@ 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
|
-
|
|
103
|
+
options.logger.error('a read that settles an append failed', error);
|
|
142
104
|
|
|
143
105
|
return null;
|
|
144
106
|
});
|
|
145
107
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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();
|
|
108
|
+
return pickSettledAttempt(
|
|
109
|
+
stored,
|
|
110
|
+
facts.map((fact) => fact.id),
|
|
111
|
+
tail,
|
|
112
|
+
);
|
|
167
113
|
}
|
|
168
114
|
|
|
169
115
|
// A read can end before `count` records, so it repeats from the next offset until it has them all
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -20,4 +20,3 @@ export { readImpIdentityTrust } from './sender-proof/imp-identity/read-imp-ident
|
|
|
20
20
|
export type { ImpIdentityTrust } from './sender-proof/imp-identity/types.ts';
|
|
21
21
|
export { noSenderProof } from './sender-proof/none/no-sender-proof.ts';
|
|
22
22
|
export { serveIngest } from './serve/serve-ingest.ts';
|
|
23
|
-
export type * from './source-port/types.ts';
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { CheckedDeployment, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
|
|
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 {
|
|
4
|
-
import { planBatch } from '../receive/plan-batch.ts';
|
|
3
|
+
import { pickBatchPlan } from '../receive/pick-batch-plan.ts';
|
|
5
4
|
import type { ResyncMarks } from '../receive/resync-marks/create-resync-marks.ts';
|
|
6
5
|
import { writeBatch } from '../receive/write-batch.ts';
|
|
7
6
|
import type { StreamTurns } from '../receive/write-batch.ts';
|
|
@@ -20,6 +19,7 @@ export type PullRunner = (
|
|
|
20
19
|
source: string,
|
|
21
20
|
path: PullPathName,
|
|
22
21
|
cursor: string | null,
|
|
22
|
+
signal: AbortSignal,
|
|
23
23
|
) => Promise<PullRun>;
|
|
24
24
|
|
|
25
25
|
interface PullRunnerDependencies {
|
|
@@ -31,13 +31,14 @@ interface PullRunnerDependencies {
|
|
|
31
31
|
turns: StreamTurns;
|
|
32
32
|
resyncMarks: ResyncMarks;
|
|
33
33
|
now: () => number;
|
|
34
|
+
logger: Logger;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// For a resync pull, removes each mark it was given that no tie set again since. A pull's fact is
|
|
37
38
|
// noted in no mark: a resynced fact ties with the body it replaces, and would mark its entity again
|
|
38
39
|
// on every run.
|
|
39
40
|
export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner {
|
|
40
|
-
return async (sourceName, pathName, cursor) => {
|
|
41
|
+
return async (sourceName, pathName, cursor, signal) => {
|
|
41
42
|
const source = dependencies.deployment.sources.find(
|
|
42
43
|
(candidate) => candidate.name === sourceName,
|
|
43
44
|
);
|
|
@@ -55,26 +56,22 @@ export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner
|
|
|
55
56
|
env[name] = dependencies.secrets[name] ?? '';
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
const pulled = await path.pull(cursor, {
|
|
59
|
+
const pulled = await path.pull(cursor, {
|
|
60
|
+
env,
|
|
61
|
+
marks,
|
|
62
|
+
now: dependencies.now,
|
|
63
|
+
log: dependencies.logger,
|
|
64
|
+
signal,
|
|
65
|
+
});
|
|
59
66
|
|
|
60
67
|
if (pulled.kind !== 'batch') {
|
|
61
68
|
return pulled;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
if (problem !== undefined) {
|
|
67
|
-
return { kind: 'refused', reason: problem };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (pulled.batch.facts.some((pending) => pending.blobs.length > 0)) {
|
|
71
|
-
return { kind: 'refused', reason: 'ingest has no blob store' };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const plan = planBatch(pulled.batch);
|
|
71
|
+
const plan = pickBatchPlan(source, pulled.batch);
|
|
75
72
|
|
|
76
73
|
if (!plan.ok) {
|
|
77
|
-
return { kind: 'refused', reason:
|
|
74
|
+
return { kind: 'refused', reason: plan.failure.reason };
|
|
78
75
|
}
|
|
79
76
|
|
|
80
77
|
const outcome = await writeBatch(plan.value, dependencies);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createLogger } from '@pepys/kit';
|
|
2
|
+
import type { CheckedDeployment, Logger, SenderProof } from '@pepys/kit';
|
|
2
3
|
import type { FactLog } from '../fact-log/fact-log.ts';
|
|
3
4
|
import { makeBatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
4
5
|
import { makePullRunner } from '../pull/make-pull-runner.ts';
|
|
@@ -20,6 +21,9 @@ export interface IngestOptions {
|
|
|
20
21
|
senderProof: SenderProof;
|
|
21
22
|
appendRetry: { maxAttempts: number; retryDelayMs: number };
|
|
22
23
|
now: () => number;
|
|
24
|
+
|
|
25
|
+
// Defaults to a logger that writes to stderr. A source's `read` and `pull` get it as `log`.
|
|
26
|
+
logger?: Logger | undefined;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export interface Ingest {
|
|
@@ -38,7 +42,8 @@ const recentBodyMaxAgeMs = 3_600_000;
|
|
|
38
42
|
|
|
39
43
|
// The receive routes of a deployment over a given log, as one fetch function with no listener.
|
|
40
44
|
export function createIngest(options: IngestOptions): Ingest {
|
|
41
|
-
const
|
|
45
|
+
const logger = options.logger ?? createLogger(process.stderr);
|
|
46
|
+
const append = makeBatchAppender({ log: options.log, logger, ...options.appendRetry });
|
|
42
47
|
|
|
43
48
|
const turns: StreamTurns = new Map();
|
|
44
49
|
|
|
@@ -60,6 +65,7 @@ export function createIngest(options: IngestOptions): Ingest {
|
|
|
60
65
|
makeReceiveHandler(source, path, {
|
|
61
66
|
secret,
|
|
62
67
|
senderProof: options.senderProof,
|
|
68
|
+
logger,
|
|
63
69
|
append,
|
|
64
70
|
turns,
|
|
65
71
|
recentBodies: createRecentBodies({
|
|
@@ -74,7 +80,7 @@ export function createIngest(options: IngestOptions): Ingest {
|
|
|
74
80
|
for (const route of path.ignoredRoutes ?? []) {
|
|
75
81
|
handlers.set(
|
|
76
82
|
route,
|
|
77
|
-
makeIgnoredRouteHandler(path, { secret, senderProof: options.senderProof }),
|
|
83
|
+
makeIgnoredRouteHandler(path, { secret, senderProof: options.senderProof, logger }),
|
|
78
84
|
);
|
|
79
85
|
}
|
|
80
86
|
}
|
|
@@ -99,6 +105,7 @@ export function createIngest(options: IngestOptions): Ingest {
|
|
|
99
105
|
turns,
|
|
100
106
|
resyncMarks: resync.marks,
|
|
101
107
|
now: options.now,
|
|
108
|
+
logger,
|
|
102
109
|
}),
|
|
103
110
|
};
|
|
104
111
|
}
|
|
@@ -3,7 +3,10 @@ import type { Batch, SourceContext } from '@pepys/kit';
|
|
|
3
3
|
|
|
4
4
|
// Every fact is checked before any is written: its stream must be one of the source's families, and
|
|
5
5
|
// its envelope must parse.
|
|
6
|
-
export function findBatchProblem(
|
|
6
|
+
export function findBatchProblem(
|
|
7
|
+
source: Pick<SourceContext, 'name' | 'families'>,
|
|
8
|
+
batch: Batch,
|
|
9
|
+
): string | undefined {
|
|
7
10
|
const streams = new Set<string>(source.families.map((family) => family.stream));
|
|
8
11
|
|
|
9
12
|
for (const [index, pending] of batch.facts.entries()) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { isDeliveryAuthentic } from '@pepys/kit';
|
|
3
3
|
import type {
|
|
4
|
+
Logger,
|
|
4
5
|
ReceivePath,
|
|
5
6
|
Received,
|
|
6
7
|
Sender,
|
|
@@ -12,8 +13,7 @@ import { match } from 'ts-pattern';
|
|
|
12
13
|
import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
13
14
|
import { buildFailureAnswer, buildRetryAnswer } from './answers/build-failure-answer.ts';
|
|
14
15
|
import { buildSuccessAnswer } from './answers/build-success-answer.ts';
|
|
15
|
-
import {
|
|
16
|
-
import { planBatch } from './plan-batch.ts';
|
|
16
|
+
import { pickReceivedStep } from './pick-received-step.ts';
|
|
17
17
|
import { readDecodedBody } from './read-decoded-body.ts';
|
|
18
18
|
import { readSentBody } from './read-sent-body.ts';
|
|
19
19
|
import type { RecentBodies } from './recent-bodies/create-recent-bodies.ts';
|
|
@@ -25,6 +25,7 @@ export interface ReceiveDependencies {
|
|
|
25
25
|
// the value of the path's `check.secretEnv`; an empty secret passes no delivery
|
|
26
26
|
secret: string;
|
|
27
27
|
senderProof: SenderProof;
|
|
28
|
+
logger: Logger;
|
|
28
29
|
append: BatchAppender;
|
|
29
30
|
turns: StreamTurns;
|
|
30
31
|
recentBodies: RecentBodies;
|
|
@@ -77,51 +78,33 @@ export function makeReceiveHandler(
|
|
|
77
78
|
return pickDecodeFailure(body);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
const received = readSourceDelivery(source, path, {
|
|
81
|
+
const received = readSourceDelivery(source, path, dependencies.logger, {
|
|
81
82
|
headers: request.headers,
|
|
82
83
|
body,
|
|
83
84
|
...(checked.sender === undefined ? {} : { sender: checked.sender }),
|
|
84
85
|
});
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
return buildFailureAnswer(500, 'the source could not read the delivery');
|
|
88
|
-
}
|
|
87
|
+
const step = pickReceivedStep(source, received, factLimit);
|
|
89
88
|
|
|
90
|
-
if (
|
|
91
|
-
return buildFailureAnswer(
|
|
89
|
+
if (step.kind === 'refuse') {
|
|
90
|
+
return buildFailureAnswer(step.status, step.reason);
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
if (
|
|
93
|
+
if (step.kind === 'ignore') {
|
|
95
94
|
return buildSuccessAnswer(path, 'ignored', 0);
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (facts.length > factLimit) {
|
|
101
|
-
return buildFailureAnswer(413, `a batch holds ${factLimit} facts at most`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const problem = findBatchProblem(source, received.batch);
|
|
105
|
-
|
|
106
|
-
if (problem !== undefined) {
|
|
107
|
-
return buildFailureAnswer(422, problem);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (facts.some((pending) => pending.blobs.length > 0)) {
|
|
111
|
-
console.error(
|
|
97
|
+
if (step.kind === 'has-blob') {
|
|
98
|
+
dependencies.logger.error(
|
|
112
99
|
`the source ${source.name} read a fact with a blob, and ingest has no blob store`,
|
|
113
100
|
);
|
|
114
101
|
|
|
115
102
|
return buildFailureAnswer(500, 'ingest has no blob store');
|
|
116
103
|
}
|
|
117
104
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
if (!plan.ok) {
|
|
121
|
-
return buildFailureAnswer(413, 'one fact is larger than one append of the log takes');
|
|
122
|
-
}
|
|
105
|
+
const facts = step.facts;
|
|
123
106
|
|
|
124
|
-
const outcome = await writeBatch(plan
|
|
107
|
+
const outcome = await writeBatch(step.plan, dependencies);
|
|
125
108
|
|
|
126
109
|
if (outcome === 'not-stored') {
|
|
127
110
|
return buildRetryAnswer();
|
|
@@ -152,7 +135,7 @@ export function makeReceiveHandler(
|
|
|
152
135
|
// the ignored answer. It reads no body further and stores nothing.
|
|
153
136
|
export function makeIgnoredRouteHandler(
|
|
154
137
|
path: ReceivePath,
|
|
155
|
-
dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
|
|
138
|
+
dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof' | 'logger'>,
|
|
156
139
|
): Handler {
|
|
157
140
|
return async (request) => {
|
|
158
141
|
const checked = await readCheckedDelivery(request, path, dependencies);
|
|
@@ -170,7 +153,7 @@ type CheckedDelivery =
|
|
|
170
153
|
async function readCheckedDelivery(
|
|
171
154
|
request: Request,
|
|
172
155
|
path: ReceivePath,
|
|
173
|
-
dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof'>,
|
|
156
|
+
dependencies: Pick<ReceiveDependencies, 'secret' | 'senderProof' | 'logger'>,
|
|
174
157
|
): Promise<CheckedDelivery> {
|
|
175
158
|
if (request.method !== 'POST') {
|
|
176
159
|
return { kind: 'answer', answer: buildFailureAnswer(405, 'method not allowed') };
|
|
@@ -190,7 +173,7 @@ async function readCheckedDelivery(
|
|
|
190
173
|
return { kind: 'checked', sent, sender: undefined };
|
|
191
174
|
}
|
|
192
175
|
|
|
193
|
-
const sender = await readProvenSender(path.sender, request.headers, dependencies
|
|
176
|
+
const sender = await readProvenSender(path.sender, request.headers, dependencies);
|
|
194
177
|
|
|
195
178
|
if (sender === null) {
|
|
196
179
|
return { kind: 'answer', answer: buildFailureAnswer(401, 'not authorised') };
|
|
@@ -203,14 +186,14 @@ async function readCheckedDelivery(
|
|
|
203
186
|
async function readProvenSender(
|
|
204
187
|
requirement: SenderRequirement,
|
|
205
188
|
headers: Headers,
|
|
206
|
-
|
|
189
|
+
dependencies: Pick<ReceiveDependencies, 'senderProof' | 'logger'>,
|
|
207
190
|
): Promise<Sender | null> {
|
|
208
191
|
try {
|
|
209
|
-
const sender = await senderProof.prove(requirement, headers);
|
|
192
|
+
const sender = await dependencies.senderProof.prove(requirement, headers);
|
|
210
193
|
|
|
211
194
|
return sender;
|
|
212
195
|
} catch {
|
|
213
|
-
|
|
196
|
+
dependencies.logger.error('the sender proof threw, so the delivery proves no sender');
|
|
214
197
|
|
|
215
198
|
return null;
|
|
216
199
|
}
|
|
@@ -233,12 +216,13 @@ function pickDecodeFailure(failure: 'unsupported' | 'too-large' | 'corrupt'): Re
|
|
|
233
216
|
function readSourceDelivery(
|
|
234
217
|
source: SourceDefinition,
|
|
235
218
|
path: ReceivePath,
|
|
219
|
+
logger: Logger,
|
|
236
220
|
delivery: Parameters<ReceivePath['read']>[0],
|
|
237
221
|
): Received | 'threw' {
|
|
238
222
|
try {
|
|
239
|
-
return path.read(delivery, { name: source.name, families: source.families });
|
|
223
|
+
return path.read(delivery, { name: source.name, families: source.families, log: logger });
|
|
240
224
|
} catch (error: unknown) {
|
|
241
|
-
|
|
225
|
+
logger.error(`the read of the source ${source.name} threw`, error);
|
|
242
226
|
|
|
243
227
|
return 'threw';
|
|
244
228
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Batch, Result, SourceContext } from '@pepys/kit';
|
|
2
|
+
import { findBatchProblem } from './find-batch-problem.ts';
|
|
3
|
+
import { planBatch } from './plan-batch.ts';
|
|
4
|
+
import type { BatchPlan } from './plan-batch.ts';
|
|
5
|
+
|
|
6
|
+
export interface BatchRefusal {
|
|
7
|
+
kind: 'bad-batch' | 'has-blob' | 'too-large';
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The checks a batch passes before any of it is written, in order: every fact's stream and
|
|
12
|
+
// envelope, no fact with a blob, then the cut into appends.
|
|
13
|
+
export function pickBatchPlan(
|
|
14
|
+
source: Pick<SourceContext, 'name' | 'families'>,
|
|
15
|
+
batch: Batch,
|
|
16
|
+
): Result<BatchPlan, BatchRefusal> {
|
|
17
|
+
const problem = findBatchProblem(source, batch);
|
|
18
|
+
|
|
19
|
+
if (problem !== undefined) {
|
|
20
|
+
return { ok: false, failure: { kind: 'bad-batch', reason: problem } };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (batch.facts.some((pending) => pending.blobs.length > 0)) {
|
|
24
|
+
return { ok: false, failure: { kind: 'has-blob', reason: 'ingest has no blob store' } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const plan = planBatch(batch);
|
|
28
|
+
|
|
29
|
+
if (!plan.ok) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
failure: { kind: 'too-large', reason: 'one fact is larger than one append of the log takes' },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return plan;
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PendingFact, Received, SourceContext } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import { pickBatchPlan } from './pick-batch-plan.ts';
|
|
4
|
+
import type { BatchPlan } from './plan-batch.ts';
|
|
5
|
+
|
|
6
|
+
// `has-blob`: the source read a fact with a blob, a fault of the deployment that ingest answers 500.
|
|
7
|
+
export type ReceivedStep =
|
|
8
|
+
| { kind: 'refuse'; status: number; reason: string }
|
|
9
|
+
| { kind: 'has-blob' }
|
|
10
|
+
| { kind: 'ignore' }
|
|
11
|
+
| { kind: 'write'; facts: readonly PendingFact[]; plan: BatchPlan };
|
|
12
|
+
|
|
13
|
+
// What follows the source's read of a delivery, `threw` when the read threw.
|
|
14
|
+
export function pickReceivedStep(
|
|
15
|
+
source: Pick<SourceContext, 'name' | 'families'>,
|
|
16
|
+
received: Received | 'threw',
|
|
17
|
+
factLimit: number,
|
|
18
|
+
): ReceivedStep {
|
|
19
|
+
if (received === 'threw') {
|
|
20
|
+
return { kind: 'refuse', status: 500, reason: 'the source could not read the delivery' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (received.kind === 'refused') {
|
|
24
|
+
return { kind: 'refuse', status: received.status, reason: received.reason };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (received.kind === 'ignored' || received.batch.facts.length === 0) {
|
|
28
|
+
return { kind: 'ignore' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const facts = received.batch.facts;
|
|
32
|
+
|
|
33
|
+
if (facts.length > factLimit) {
|
|
34
|
+
return { kind: 'refuse', status: 413, reason: `a batch holds ${factLimit} facts at most` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const plan = pickBatchPlan(source, received.batch);
|
|
38
|
+
|
|
39
|
+
if (!plan.ok) {
|
|
40
|
+
return match(plan.failure.kind)
|
|
41
|
+
.with('bad-batch', () => ({
|
|
42
|
+
kind: 'refuse' as const,
|
|
43
|
+
status: 422,
|
|
44
|
+
reason: plan.failure.reason,
|
|
45
|
+
}))
|
|
46
|
+
.with('has-blob', () => ({ kind: 'has-blob' as const }))
|
|
47
|
+
.with('too-large', () => ({
|
|
48
|
+
kind: 'refuse' as const,
|
|
49
|
+
status: 413,
|
|
50
|
+
reason: plan.failure.reason,
|
|
51
|
+
}))
|
|
52
|
+
.exhaustive();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { kind: 'write', facts, plan: plan.value };
|
|
56
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StreamName } from '@pepys/kit';
|
|
1
|
+
import type { Logger, StreamName } from '@pepys/kit';
|
|
2
2
|
import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
3
3
|
import type { BatchPlan } from './plan-batch.ts';
|
|
4
4
|
|
|
@@ -11,6 +11,7 @@ export type StreamTurns = Map<StreamName, Promise<void>>;
|
|
|
11
11
|
interface WriteBatchDependencies {
|
|
12
12
|
append: BatchAppender;
|
|
13
13
|
turns: StreamTurns;
|
|
14
|
+
logger: Logger;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// The streams append at the same time, and the appends of one stream one after the other. Every
|
|
@@ -21,12 +22,12 @@ export async function writeBatch(
|
|
|
21
22
|
): Promise<BatchOutcome> {
|
|
22
23
|
const settled = await Promise.all(
|
|
23
24
|
[...plan].map(([stream, runs]) =>
|
|
24
|
-
withStreamTurn(dependencies
|
|
25
|
+
withStreamTurn(dependencies, stream, async () => {
|
|
25
26
|
for (const run of runs) {
|
|
26
27
|
const appended = await dependencies.append(stream, run);
|
|
27
28
|
|
|
28
29
|
if (!appended.ok) {
|
|
29
|
-
|
|
30
|
+
dependencies.logger.error(`an append to ${stream} failed`, appended.failure);
|
|
30
31
|
|
|
31
32
|
return appended.failure.kind;
|
|
32
33
|
}
|
|
@@ -42,10 +43,11 @@ export async function writeBatch(
|
|
|
42
43
|
|
|
43
44
|
// A throw is a refusal the log gives no outcome for, such as a bad access token.
|
|
44
45
|
async function withStreamTurn(
|
|
45
|
-
|
|
46
|
+
dependencies: Pick<WriteBatchDependencies, 'turns' | 'logger'>,
|
|
46
47
|
stream: StreamName,
|
|
47
48
|
work: () => Promise<BatchOutcome>,
|
|
48
49
|
): Promise<BatchOutcome> {
|
|
50
|
+
const turns = dependencies.turns;
|
|
49
51
|
const previous = turns.get(stream) ?? Promise.resolve();
|
|
50
52
|
const done = Promise.withResolvers<void>();
|
|
51
53
|
|
|
@@ -56,7 +58,7 @@ async function withStreamTurn(
|
|
|
56
58
|
|
|
57
59
|
return await work();
|
|
58
60
|
} catch (error: unknown) {
|
|
59
|
-
|
|
61
|
+
dependencies.logger.error(`an append to ${stream} threw`, error);
|
|
60
62
|
|
|
61
63
|
return 'refused';
|
|
62
64
|
} finally {
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { verifyImpIdentity } from '@impz/client/identity';
|
|
2
|
-
import
|
|
2
|
+
import { createLogger } from '@pepys/kit';
|
|
3
|
+
import type { Logger, SenderProof } from '@pepys/kit';
|
|
3
4
|
import type { ImpIdentityTrust } from './types.ts';
|
|
4
5
|
|
|
5
6
|
interface ImpIdentityOptions {
|
|
6
7
|
verify: typeof verifyImpIdentity;
|
|
7
8
|
trust: ImpIdentityTrust;
|
|
8
9
|
now: () => number;
|
|
10
|
+
|
|
11
|
+
// defaults to a logger that writes to stderr
|
|
12
|
+
logger?: Logger | undefined;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
// Proves the sender from the signed identity token in the `imp-identity` header. The issuer and
|
|
12
16
|
// the subject come only from the verified token, and every failure, a throw included, proves
|
|
13
17
|
// nothing. It never logs the token.
|
|
14
18
|
export function createImpIdentitySenderProof(options: ImpIdentityOptions): SenderProof {
|
|
19
|
+
const logger = options.logger ?? createLogger(process.stderr);
|
|
20
|
+
|
|
15
21
|
return {
|
|
16
22
|
prove: async (requirement, headers) => {
|
|
17
23
|
const documents = options.trust.issuers.get(requirement.issuersEnv) ?? [];
|
|
@@ -33,7 +39,7 @@ export function createImpIdentitySenderProof(options: ImpIdentityOptions): Sende
|
|
|
33
39
|
? null
|
|
34
40
|
: { issuer: verified.issuer, subject: verified.id, name: verified.name };
|
|
35
41
|
} catch {
|
|
36
|
-
|
|
42
|
+
logger.error('the identity verifier failed, so the delivery proves no sender');
|
|
37
43
|
|
|
38
44
|
return null;
|
|
39
45
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { verifyImpIdentity } from '@impz/client/identity';
|
|
2
|
-
import type { Result, SenderProof, SourceDefinition } from '@pepys/kit';
|
|
2
|
+
import type { Logger, Result, SenderProof, SourceDefinition } from '@pepys/kit';
|
|
3
3
|
import { createImpIdentitySenderProof } from './create-imp-identity-sender-proof.ts';
|
|
4
4
|
import { readImpIdentityTrust } from './read-imp-identity-trust.ts';
|
|
5
5
|
|
|
@@ -7,6 +7,9 @@ interface SenderProofReadOptions {
|
|
|
7
7
|
sources: readonly SourceDefinition[];
|
|
8
8
|
environment: Readonly<Record<string, string | undefined>>;
|
|
9
9
|
now: () => number;
|
|
10
|
+
|
|
11
|
+
// defaults to a logger that writes to stderr
|
|
12
|
+
logger?: Logger | undefined;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
// The sender proof that `serveIngest` runs: the trust of the deployment's environment and the
|
|
@@ -30,6 +33,7 @@ export async function readImpIdentitySenderProof(
|
|
|
30
33
|
verify: verifyImpIdentity,
|
|
31
34
|
trust: trust.value,
|
|
32
35
|
now: options.now,
|
|
36
|
+
logger: options.logger,
|
|
33
37
|
}),
|
|
34
38
|
};
|
|
35
39
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { checkDeployment, listReceiveRoutes } from '@pepys/kit';
|
|
1
|
+
import { checkDeployment, createLogger, listReceiveRoutes } from '@pepys/kit';
|
|
2
2
|
import type { Deployment, SourceDefinition, StreamName } from '@pepys/kit';
|
|
3
3
|
import { makeShutdownHandler } from '@pepys/schema';
|
|
4
4
|
import { toTagged } from '@zgeoff/std';
|
|
@@ -11,16 +11,20 @@ import { startPullTimers } from './start-pull-timers.ts';
|
|
|
11
11
|
interface ServeIngestOptions {
|
|
12
12
|
deployment: Deployment;
|
|
13
13
|
environment: Readonly<Record<string, string | undefined>>;
|
|
14
|
+
|
|
15
|
+
// false leaves SIGINT and SIGTERM to the caller, who stops ingest through the returned function
|
|
16
|
+
exitOnSignal?: boolean;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
// The ingest process of a deployment. It exits 1 at start on a failed deployment check, on any
|
|
17
20
|
// missing variable, or on a trust folder it cannot load, so no route ever runs with an empty secret.
|
|
18
21
|
// A path whose trust documents or audience are unset starts, and refuses every delivery.
|
|
19
|
-
export async function serveIngest(options: ServeIngestOptions): Promise<void
|
|
22
|
+
export async function serveIngest(options: ServeIngestOptions): Promise<() => Promise<void>> {
|
|
23
|
+
const logger = createLogger(process.stderr);
|
|
20
24
|
const checked = checkDeployment(options.deployment);
|
|
21
25
|
|
|
22
26
|
if (!checked.ok) {
|
|
23
|
-
|
|
27
|
+
logger.error('the deployment fails its check', checked.failure);
|
|
24
28
|
process.exit(1);
|
|
25
29
|
}
|
|
26
30
|
|
|
@@ -28,7 +32,7 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
28
32
|
const config = readIngestConfig(options.environment, collectSecretNames(sources));
|
|
29
33
|
|
|
30
34
|
if (!config.ok) {
|
|
31
|
-
|
|
35
|
+
logger.error('the ingest config is incomplete', config.failure);
|
|
32
36
|
process.exit(1);
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -36,15 +40,20 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
36
40
|
sources,
|
|
37
41
|
environment: options.environment,
|
|
38
42
|
now: () => Date.now(),
|
|
43
|
+
logger,
|
|
39
44
|
});
|
|
40
45
|
|
|
41
46
|
if (!senderProof.ok) {
|
|
42
|
-
|
|
47
|
+
logger.error('the sender proof cannot start', senderProof.failure);
|
|
43
48
|
process.exit(1);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
// The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
|
|
47
|
-
const log = await createS2FactLog({
|
|
52
|
+
const log = await createS2FactLog({
|
|
53
|
+
...config.value.s2,
|
|
54
|
+
streams: collectStreams(sources),
|
|
55
|
+
logger,
|
|
56
|
+
});
|
|
48
57
|
|
|
49
58
|
const ingest = createIngest({
|
|
50
59
|
deployment: checked.value,
|
|
@@ -53,30 +62,38 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
53
62
|
senderProof: senderProof.value,
|
|
54
63
|
appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
|
|
55
64
|
now: () => Date.now(),
|
|
65
|
+
logger,
|
|
56
66
|
});
|
|
57
67
|
|
|
58
68
|
for (const route of listReceiveRoutes(options.deployment)) {
|
|
59
69
|
console.log(`route ${route.route}`, JSON.stringify(route));
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
// The server starts first, so a failed listen leaves no timer behind.
|
|
64
73
|
const server = Bun.serve({
|
|
65
74
|
hostname: config.value.host,
|
|
66
75
|
port: config.value.port,
|
|
67
76
|
fetch: ingest.handleRequest,
|
|
68
77
|
});
|
|
69
78
|
|
|
79
|
+
const stopPulls = startPullTimers(sources, ingest.runPull, logger);
|
|
80
|
+
|
|
70
81
|
console.log(`ingest listening on ${server.hostname}:${server.port}`);
|
|
71
82
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
stopPulls();
|
|
83
|
+
const stop = async (): Promise<void> => {
|
|
84
|
+
await stopPulls();
|
|
75
85
|
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
await server.stop();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (options.exitOnSignal === false) {
|
|
90
|
+
return stop;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const shutdown = makeShutdownHandler({
|
|
94
|
+
stop,
|
|
78
95
|
logError: (message, error) => {
|
|
79
|
-
|
|
96
|
+
logger.error(message, error);
|
|
80
97
|
},
|
|
81
98
|
exit: (code) => process.exit(code),
|
|
82
99
|
});
|
|
@@ -86,6 +103,8 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
86
103
|
void shutdown();
|
|
87
104
|
});
|
|
88
105
|
}
|
|
106
|
+
|
|
107
|
+
return stop;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
// every receive path's secret, and every name a pull path's `env` lists
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import type { SourceDefinition } from '@pepys/kit';
|
|
1
|
+
import type { Logger, SourceDefinition } from '@pepys/kit';
|
|
2
2
|
import { match } from 'ts-pattern';
|
|
3
3
|
import type { PullPathName, PullRun, PullRunner } from '../pull/make-pull-runner.ts';
|
|
4
4
|
|
|
5
5
|
const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
|
|
6
6
|
|
|
7
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
|
|
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.
|
|
9
10
|
export function startPullTimers(
|
|
10
11
|
sources: readonly SourceDefinition[],
|
|
11
12
|
runPull: PullRunner,
|
|
12
|
-
|
|
13
|
+
logger: Logger,
|
|
14
|
+
): () => Promise<void> {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const activeRuns = new Set<Promise<void>>();
|
|
17
|
+
|
|
13
18
|
const timers = sources.flatMap((source) =>
|
|
14
19
|
pullPathNames.flatMap((pathName) => {
|
|
15
20
|
const path = source[pathName];
|
|
@@ -22,24 +27,34 @@ export function startPullTimers(
|
|
|
22
27
|
let isRunning = false;
|
|
23
28
|
|
|
24
29
|
const runOnce = async (): Promise<void> => {
|
|
25
|
-
if (isRunning) {
|
|
30
|
+
if (isRunning || controller.signal.aborted) {
|
|
26
31
|
return;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
isRunning = true;
|
|
30
35
|
|
|
36
|
+
const ended = Promise.withResolvers<void>();
|
|
37
|
+
|
|
38
|
+
activeRuns.add(ended.promise);
|
|
39
|
+
|
|
31
40
|
try {
|
|
32
|
-
const run = await runPull(source.name, pathName, cursor);
|
|
41
|
+
const run = await runPull(source.name, pathName, cursor, controller.signal);
|
|
33
42
|
|
|
34
43
|
cursor = pickNextCursor(run, cursor);
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
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);
|
|
38
48
|
}
|
|
39
49
|
} catch (error: unknown) {
|
|
40
|
-
|
|
50
|
+
if (!controller.signal.aborted) {
|
|
51
|
+
logger.error(`the ${pathName} pull of ${source.name} threw`, error);
|
|
52
|
+
}
|
|
41
53
|
} finally {
|
|
42
54
|
isRunning = false;
|
|
55
|
+
|
|
56
|
+
activeRuns.delete(ended.promise);
|
|
57
|
+
ended.resolve();
|
|
43
58
|
}
|
|
44
59
|
};
|
|
45
60
|
|
|
@@ -51,10 +66,14 @@ export function startPullTimers(
|
|
|
51
66
|
}),
|
|
52
67
|
);
|
|
53
68
|
|
|
54
|
-
return () => {
|
|
69
|
+
return async () => {
|
|
55
70
|
for (const timer of timers) {
|
|
56
71
|
clearInterval(timer);
|
|
57
72
|
}
|
|
73
|
+
|
|
74
|
+
controller.abort();
|
|
75
|
+
|
|
76
|
+
await Promise.all(activeRuns);
|
|
58
77
|
};
|
|
59
78
|
}
|
|
60
79
|
|
package/src/source-port/types.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
BlobHash,
|
|
3
|
-
ConsumerDeclaration,
|
|
4
|
-
EntityKind,
|
|
5
|
-
FactEnvelope,
|
|
6
|
-
LinkTypeDeclaration,
|
|
7
|
-
Result,
|
|
8
|
-
StreamName,
|
|
9
|
-
} from '@pepys/kit';
|
|
10
|
-
|
|
11
|
-
export interface FactFamily<Payload> {
|
|
12
|
-
stream: StreamName;
|
|
13
|
-
types: readonly string[];
|
|
14
|
-
api: string;
|
|
15
|
-
parsePayload: (raw: unknown) => Result<Payload, { reason: string }>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Provisional: source-retention
|
|
19
|
-
export interface SourceDefinition {
|
|
20
|
-
name: string;
|
|
21
|
-
families: readonly FactFamily<unknown>[];
|
|
22
|
-
entityKinds: readonly EntityKind[];
|
|
23
|
-
linkTypes: readonly LinkTypeDeclaration[];
|
|
24
|
-
consumers: readonly ConsumerDeclaration[];
|
|
25
|
-
live: ReceivePath | PullPath;
|
|
26
|
-
recovery: PullPath;
|
|
27
|
-
backfill: PullPath;
|
|
28
|
-
resync: PullPath;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ReceivePath {
|
|
32
|
-
kind: 'receive';
|
|
33
|
-
route: string;
|
|
34
|
-
receive: (request: Request) => Promise<Received>;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type Received =
|
|
38
|
-
| { kind: 'refused'; status: 400 | 401 | 405 | 422; reason: string }
|
|
39
|
-
| { kind: 'ignored' }
|
|
40
|
-
| { kind: 'batch'; batch: Batch };
|
|
41
|
-
|
|
42
|
-
export interface PullPath {
|
|
43
|
-
kind: 'pull';
|
|
44
|
-
name: string;
|
|
45
|
-
intervalMs: number | null;
|
|
46
|
-
pull: (cursor: string | null) => Promise<Pulled>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Provisional: source-retention
|
|
50
|
-
export type Pulled =
|
|
51
|
-
| { kind: 'batch'; batch: Batch; cursor: string; isDone: boolean }
|
|
52
|
-
| { kind: 'expired' }
|
|
53
|
-
| { kind: 'unavailable'; retryAfterMs: number }
|
|
54
|
-
| { kind: 'rejected'; reason: string };
|
|
55
|
-
|
|
56
|
-
export interface Batch {
|
|
57
|
-
facts: readonly PendingFact[];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface PendingFact {
|
|
61
|
-
stream: StreamName;
|
|
62
|
-
fact: FactEnvelope<unknown>;
|
|
63
|
-
blobs: readonly { hash: BlobHash; bytes: Uint8Array }[];
|
|
64
|
-
}
|