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