@pepys/ingest 0.0.0-stage → 0.1.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/README.md +34 -2
- package/package.json +33 -4
- package/src/blob-store/blob-store.ts +6 -0
- package/src/blob-store/build-blob-hash.ts +6 -0
- package/src/blob-store/garage/create-garage-blob-store.ts +82 -0
- package/src/blob-store/local-folder/create-local-folder-blob-store.ts +56 -0
- package/src/config/read-ingest-config.ts +50 -0
- package/src/fact-log/check-append-batch.ts +45 -0
- package/src/fact-log/fact-log.ts +35 -0
- package/src/fact-log/make-batch-appender.ts +198 -0
- package/src/fact-log/memory/create-memory-fact-log.ts +174 -0
- package/src/fact-log/parse-stored-body.ts +18 -0
- package/src/fact-log/parse-stored-fact.ts +17 -0
- package/src/fact-log/pick-settlement.ts +20 -0
- package/src/fact-log/plan-appends.ts +45 -0
- package/src/fact-log/s2/create-s2-fact-log.ts +171 -0
- package/src/fact-log/s2/find-append-outcome.ts +35 -0
- package/src/index.ts +23 -0
- package/src/pull/make-pull-runner.ts +104 -0
- package/src/receive/answers/build-failure-answer.ts +12 -0
- package/src/receive/answers/build-success-answer.ts +19 -0
- package/src/receive/create-ingest.ts +103 -0
- package/src/receive/find-batch-problem.ts +26 -0
- package/src/receive/make-receive-handler.ts +245 -0
- package/src/receive/plan-batch.ts +31 -0
- package/src/receive/read-decoded-body.ts +33 -0
- package/src/receive/read-sent-body.ts +22 -0
- package/src/receive/read-stream-up-to.ts +44 -0
- package/src/receive/recent-bodies/create-recent-bodies.ts +55 -0
- package/src/receive/resync-marks/collect-returned-entities.ts +16 -0
- package/src/receive/resync-marks/collect-tied-entities.ts +29 -0
- package/src/receive/resync-marks/create-resync-marks.ts +111 -0
- package/src/receive/write-batch.ts +79 -0
- package/src/sender-proof/fake/create-fake-sender-proof.ts +14 -0
- package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +42 -0
- package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +35 -0
- package/src/sender-proof/imp-identity/read-imp-identity-trust.ts +88 -0
- package/src/sender-proof/imp-identity/types.ts +9 -0
- package/src/sender-proof/none/no-sender-proof.ts +7 -0
- package/src/serve/serve-ingest.ts +107 -0
- package/src/serve/start-pull-timers.ts +69 -0
- package/src/source-port/types.ts +64 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
|
|
2
|
+
import { toTagged } from '@zgeoff/std';
|
|
3
|
+
import invariant from 'tiny-invariant';
|
|
4
|
+
import { checkAppendBatch } from '../check-append-batch.ts';
|
|
5
|
+
import type { AppendBatchOutcome, FactLog, StoredFact } from '../fact-log.ts';
|
|
6
|
+
import { parseStoredFact } from '../parse-stored-fact.ts';
|
|
7
|
+
|
|
8
|
+
interface OtherWriter {
|
|
9
|
+
thenAppend: readonly FactEnvelope<unknown>[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MemoryFactLog extends FactLog {
|
|
13
|
+
// The next append is stored whole, and its outcome is reported as unknown.
|
|
14
|
+
loseNextReply: () => void;
|
|
15
|
+
|
|
16
|
+
// The next append is not stored and its outcome is reported as unknown. Then `thenAppend` is
|
|
17
|
+
// stored at the tail, as a second writer on the stream would store it.
|
|
18
|
+
dropNextAppend: (other: OtherWriter) => void;
|
|
19
|
+
|
|
20
|
+
// The next append reports unknown and is not stored yet. It is stored, whole, just before the
|
|
21
|
+
// append after it is checked, so that one gets a tail mismatch.
|
|
22
|
+
landNextAppendLate: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface MemoryRecord {
|
|
26
|
+
body: string;
|
|
27
|
+
appendedAtMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Fault =
|
|
31
|
+
| { kind: 'lose-reply' }
|
|
32
|
+
| { kind: 'drop'; thenAppend: readonly FactEnvelope<unknown>[] }
|
|
33
|
+
| { kind: 'land-late' };
|
|
34
|
+
|
|
35
|
+
interface LateAppend {
|
|
36
|
+
stream: StreamName;
|
|
37
|
+
bodies: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface MemoryState {
|
|
41
|
+
records: Map<StreamName, MemoryRecord[]>;
|
|
42
|
+
fault: Fault | null;
|
|
43
|
+
late: LateAppend | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createMemoryFactLog(streams: readonly StreamName[]): MemoryFactLog {
|
|
47
|
+
const state: MemoryState = {
|
|
48
|
+
records: new Map(streams.map((stream) => [stream, []])),
|
|
49
|
+
fault: null,
|
|
50
|
+
late: null,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
appendBatch: (stream, facts, expectedTail) =>
|
|
55
|
+
Promise.resolve(appendBatch(state, stream, facts, expectedTail)),
|
|
56
|
+
read: (stream, from, count) =>
|
|
57
|
+
Promise.resolve(collectStoredFacts(state.records.get(stream), from, count)),
|
|
58
|
+
readTail: (stream) => Promise.resolve(getTail(state.records.get(stream))),
|
|
59
|
+
loseNextReply: () => {
|
|
60
|
+
state.fault = { kind: 'lose-reply' };
|
|
61
|
+
},
|
|
62
|
+
dropNextAppend: (other) => {
|
|
63
|
+
state.fault = { kind: 'drop', thenAppend: other.thenAppend };
|
|
64
|
+
},
|
|
65
|
+
landNextAppendLate: () => {
|
|
66
|
+
state.fault = { kind: 'land-late' };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendBatch(
|
|
72
|
+
state: MemoryState,
|
|
73
|
+
stream: StreamName,
|
|
74
|
+
facts: readonly FactEnvelope<unknown>[],
|
|
75
|
+
expectedTail: StreamOffset,
|
|
76
|
+
): AppendBatchOutcome {
|
|
77
|
+
landLateAppend(state);
|
|
78
|
+
|
|
79
|
+
const fault = state.fault;
|
|
80
|
+
|
|
81
|
+
state.fault = null;
|
|
82
|
+
|
|
83
|
+
const checked = checkAppendBatch(facts);
|
|
84
|
+
|
|
85
|
+
if (!checked.ok) {
|
|
86
|
+
return { kind: 'rejected', ...checked.failure };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const stored = state.records.get(stream);
|
|
90
|
+
|
|
91
|
+
if (stored === undefined) {
|
|
92
|
+
return { kind: 'rejected', reason: 'unknown-stream' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (expectedTail !== stored.length) {
|
|
96
|
+
return { kind: 'rejected', reason: 'tail-mismatch' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (fault?.kind === 'drop') {
|
|
100
|
+
pushOtherWriter(stored, fault.thenAppend);
|
|
101
|
+
|
|
102
|
+
return { kind: 'unknown' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (fault?.kind === 'land-late') {
|
|
106
|
+
state.late = { stream, bodies: checked.value };
|
|
107
|
+
|
|
108
|
+
return { kind: 'unknown' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pushBodies(stored, checked.value);
|
|
112
|
+
|
|
113
|
+
return fault === null
|
|
114
|
+
? { kind: 'acknowledged', firstOffset: expectedTail, count: checked.value.length }
|
|
115
|
+
: { kind: 'unknown' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The append stays valid until it lands: nothing is stored between its refusal of a reply and the
|
|
119
|
+
// next call, so the tail is still the one it aimed at.
|
|
120
|
+
function landLateAppend(state: MemoryState): void {
|
|
121
|
+
const late = state.late;
|
|
122
|
+
|
|
123
|
+
state.late = null;
|
|
124
|
+
|
|
125
|
+
const stored = late === null ? undefined : state.records.get(late.stream);
|
|
126
|
+
|
|
127
|
+
if (late !== null && stored !== undefined) {
|
|
128
|
+
pushBodies(stored, late.bodies);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pushBodies(stored: MemoryRecord[], bodies: readonly string[]): void {
|
|
133
|
+
const appendedAtMs = Date.now();
|
|
134
|
+
|
|
135
|
+
for (const body of bodies) {
|
|
136
|
+
stored.push({ body, appendedAtMs });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pushOtherWriter(stored: MemoryRecord[], facts: readonly FactEnvelope<unknown>[]): void {
|
|
141
|
+
const checked = checkAppendBatch(facts);
|
|
142
|
+
|
|
143
|
+
invariant(checked.ok, 'the facts of a second writer pass the append checks');
|
|
144
|
+
pushBodies(stored, checked.value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type StreamRead<Value> = Result<Value, { reason: 'unknown-stream' }>;
|
|
148
|
+
|
|
149
|
+
function collectStoredFacts(
|
|
150
|
+
stored: readonly MemoryRecord[] | undefined,
|
|
151
|
+
from: StreamOffset,
|
|
152
|
+
count: number,
|
|
153
|
+
): StreamRead<readonly StoredFact[]> {
|
|
154
|
+
if (stored === undefined) {
|
|
155
|
+
return { ok: false, failure: { reason: 'unknown-stream' } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
value: stored
|
|
161
|
+
.slice(from, from + count)
|
|
162
|
+
.map((record, index) =>
|
|
163
|
+
parseStoredFact(toTagged<StreamOffset>(from + index), record.body, record.appendedAtMs),
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getTail(stored: readonly MemoryRecord[] | undefined): StreamRead<StreamOffset> {
|
|
169
|
+
if (stored === undefined) {
|
|
170
|
+
return { ok: false, failure: { reason: 'unknown-stream' } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { ok: true, value: toTagged<StreamOffset>(stored.length) };
|
|
174
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseFactEnvelope } from '@pepys/kit';
|
|
2
|
+
import type { FactEnvelope } from '@pepys/kit';
|
|
3
|
+
|
|
4
|
+
// Checks the JSON a log would store, so a field that JSON drops, such as a function, cannot leave
|
|
5
|
+
// a stored record that fails the envelope parse on read.
|
|
6
|
+
export function parseStoredBody(fact: FactEnvelope<unknown>): string | null {
|
|
7
|
+
let body: string;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
body = JSON.stringify(fact);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const json: unknown = JSON.parse(body);
|
|
16
|
+
|
|
17
|
+
return parseFactEnvelope(json).ok ? body : null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { parseFactEnvelope } from '@pepys/kit';
|
|
2
|
+
import type { StreamOffset } from '@pepys/kit';
|
|
3
|
+
import invariant from 'tiny-invariant';
|
|
4
|
+
import type { StoredFact } from './fact-log.ts';
|
|
5
|
+
|
|
6
|
+
export function parseStoredFact(
|
|
7
|
+
offset: StreamOffset,
|
|
8
|
+
body: string,
|
|
9
|
+
appendedAtMs: number,
|
|
10
|
+
): StoredFact {
|
|
11
|
+
const json: unknown = JSON.parse(body);
|
|
12
|
+
const fact = parseFactEnvelope(json);
|
|
13
|
+
|
|
14
|
+
invariant(fact.ok, 'the log holds only envelopes that an append parsed');
|
|
15
|
+
|
|
16
|
+
return { offset, fact: fact.value, body, appendedAtMs };
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FactID } from '@pepys/kit';
|
|
2
|
+
import type { StoredFact } from './fact-log.ts';
|
|
3
|
+
|
|
4
|
+
// `landed`: the run is stored whole at the offsets the attempt aimed at. `not-landed`: nothing is
|
|
5
|
+
// there, so the attempt may still be in flight. `taken`: another writer took the offsets, so the
|
|
6
|
+
// attempt can never land.
|
|
7
|
+
export type Settlement = 'landed' | 'not-landed' | 'taken';
|
|
8
|
+
|
|
9
|
+
// `stored` holds what the log gives for the offsets `[t, t + n)` that an attempt aimed at. One
|
|
10
|
+
// append is whole, so a match of fewer than n IDs is another writer's records, never part of the run.
|
|
11
|
+
export function pickSettlement(ids: readonly FactID[], stored: readonly StoredFact[]): Settlement {
|
|
12
|
+
if (stored.length === 0) {
|
|
13
|
+
return 'not-landed';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isSameRun =
|
|
17
|
+
stored.length === ids.length && stored.every((record, index) => record.fact.id === ids[index]);
|
|
18
|
+
|
|
19
|
+
return isSameRun ? 'landed' : 'taken';
|
|
20
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FactEnvelope, Result } from '@pepys/kit';
|
|
2
|
+
import { appendLimits, toMeteredBytes } from './check-append-batch.ts';
|
|
3
|
+
import { parseStoredBody } from './parse-stored-body.ts';
|
|
4
|
+
|
|
5
|
+
type Run = readonly FactEnvelope<unknown>[];
|
|
6
|
+
|
|
7
|
+
// Cuts the facts of one stream, in order, into runs that each stay inside the append limits. A run
|
|
8
|
+
// closes when the next fact would make it 1001 records or more than 1,048,576 metered bytes. Fact
|
|
9
|
+
// `index` is too large when it alone meters more than one append takes.
|
|
10
|
+
export function planAppends(
|
|
11
|
+
facts: readonly FactEnvelope<unknown>[],
|
|
12
|
+
): Result<readonly Run[], { reason: 'too-large'; index: number }> {
|
|
13
|
+
const runs: FactEnvelope<unknown>[][] = [];
|
|
14
|
+
let run: FactEnvelope<unknown>[] = [];
|
|
15
|
+
let runBytes = 0;
|
|
16
|
+
|
|
17
|
+
for (const [index, fact] of facts.entries()) {
|
|
18
|
+
// A fact that fails the envelope check meters as 0: the append refuses it by its own check.
|
|
19
|
+
const bytes = toMeteredBytes(parseStoredBody(fact) ?? '');
|
|
20
|
+
|
|
21
|
+
if (bytes > appendLimits.meteredBytes) {
|
|
22
|
+
return { ok: false, failure: { reason: 'too-large', index } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isFull =
|
|
26
|
+
run.length + 1 > appendLimits.records || runBytes + bytes > appendLimits.meteredBytes;
|
|
27
|
+
|
|
28
|
+
if (isFull) {
|
|
29
|
+
runs.push(run);
|
|
30
|
+
|
|
31
|
+
run = [];
|
|
32
|
+
runBytes = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
run.push(fact);
|
|
36
|
+
|
|
37
|
+
runBytes += bytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (run.length > 0) {
|
|
41
|
+
runs.push(run);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { ok: true, value: runs };
|
|
45
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { FactEnvelope, Result, StreamName, StreamOffset } from '@pepys/kit';
|
|
2
|
+
import {
|
|
3
|
+
AppendInput,
|
|
4
|
+
AppendRecord,
|
|
5
|
+
RangeNotSatisfiableError,
|
|
6
|
+
S2,
|
|
7
|
+
S2Error,
|
|
8
|
+
} from '@s2-dev/streamstore';
|
|
9
|
+
import type { S2Basin, StreamConfig } from '@s2-dev/streamstore';
|
|
10
|
+
import { toTagged } from '@zgeoff/std';
|
|
11
|
+
import { checkAppendBatch } from '../check-append-batch.ts';
|
|
12
|
+
import type { AppendBatchOutcome, FactLog, StoredFact } from '../fact-log.ts';
|
|
13
|
+
import { parseStoredFact } from '../parse-stored-fact.ts';
|
|
14
|
+
import { findAppendOutcome, isUnknownStream } from './find-append-outcome.ts';
|
|
15
|
+
|
|
16
|
+
interface S2FactLogParameters {
|
|
17
|
+
endpoint: string;
|
|
18
|
+
accessToken: string;
|
|
19
|
+
basin: string;
|
|
20
|
+
streams: readonly StreamName[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Creates the basin and the streams when they are missing. A stream that exists keeps its records
|
|
24
|
+
// and has its retention set to infinite.
|
|
25
|
+
export async function createS2FactLog(parameters: S2FactLogParameters): Promise<FactLog> {
|
|
26
|
+
const options = {
|
|
27
|
+
accessToken: parameters.accessToken,
|
|
28
|
+
endpoints: { account: parameters.endpoint, basin: parameters.endpoint },
|
|
29
|
+
requestTimeoutMillis: 5000,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const client = new S2(options);
|
|
33
|
+
|
|
34
|
+
// A client retry could store a record twice before the writer knows; the writer owns retries.
|
|
35
|
+
const appendClient = new S2({ ...options, retry: { maxAttempts: 1 } });
|
|
36
|
+
|
|
37
|
+
await ignoreExisting(client.basins.create({ basin: parameters.basin }));
|
|
38
|
+
|
|
39
|
+
const basin = client.basin(parameters.basin);
|
|
40
|
+
const appendBasin = appendClient.basin(parameters.basin);
|
|
41
|
+
|
|
42
|
+
for (const stream of parameters.streams) {
|
|
43
|
+
await createInfiniteStream(basin, stream);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
appendBatch: (stream, facts, expectedTail) =>
|
|
48
|
+
appendBatch(appendBasin, stream, facts, expectedTail),
|
|
49
|
+
read: (stream, from, count) => readFacts(basin, stream, from, count),
|
|
50
|
+
readTail: (stream) => readTail(basin, stream),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ignoreExisting(creation: Promise<unknown>): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
await creation;
|
|
57
|
+
|
|
58
|
+
return true;
|
|
59
|
+
} catch (error: unknown) {
|
|
60
|
+
if (error instanceof S2Error && error.status === 409) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// log.md: pepys creates every stream with infinite retention, because the default is 7 days.
|
|
69
|
+
const infiniteRetention: StreamConfig = { retentionPolicy: { infinite: {} } };
|
|
70
|
+
|
|
71
|
+
async function createInfiniteStream(basin: S2Basin, stream: StreamName): Promise<void> {
|
|
72
|
+
const isCreated = await ignoreExisting(
|
|
73
|
+
basin.streams.create({ stream, config: infiniteRetention }),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!isCreated) {
|
|
77
|
+
await basin.streams.reconfigure({ stream, ...infiniteRetention });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function appendBatch(
|
|
82
|
+
basin: S2Basin,
|
|
83
|
+
stream: StreamName,
|
|
84
|
+
facts: readonly FactEnvelope<unknown>[],
|
|
85
|
+
expectedTail: StreamOffset,
|
|
86
|
+
): Promise<AppendBatchOutcome> {
|
|
87
|
+
const checked = checkAppendBatch(facts);
|
|
88
|
+
|
|
89
|
+
if (!checked.ok) {
|
|
90
|
+
return { kind: 'rejected', ...checked.failure };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The checks hold every limit, so `create` does not throw.
|
|
94
|
+
const input = AppendInput.create(
|
|
95
|
+
checked.value.map((body) => AppendRecord.string({ body })),
|
|
96
|
+
{ matchSeqNum: expectedTail },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const ack = await basin.stream(stream).append(input);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
kind: 'acknowledged',
|
|
104
|
+
firstOffset: toTagged<StreamOffset>(ack.start.seqNum),
|
|
105
|
+
count: ack.end.seqNum - ack.start.seqNum,
|
|
106
|
+
};
|
|
107
|
+
} catch (error: unknown) {
|
|
108
|
+
const outcome = findAppendOutcome(error);
|
|
109
|
+
|
|
110
|
+
if (outcome === null) {
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (outcome.kind === 'unknown') {
|
|
115
|
+
console.error('an s2 append has an unknown outcome', error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return outcome;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type StreamRead<Value> = Result<Value, { reason: 'unknown-stream' }>;
|
|
123
|
+
|
|
124
|
+
async function readFacts(
|
|
125
|
+
basin: S2Basin,
|
|
126
|
+
stream: StreamName,
|
|
127
|
+
from: StreamOffset,
|
|
128
|
+
count: number,
|
|
129
|
+
): Promise<StreamRead<readonly StoredFact[]>> {
|
|
130
|
+
try {
|
|
131
|
+
const batch = await basin
|
|
132
|
+
.stream(stream)
|
|
133
|
+
.read({ start: { from: { seqNum: from } }, stop: { limits: { count } } });
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
value: batch.records.map((record) =>
|
|
138
|
+
parseStoredFact(
|
|
139
|
+
toTagged<StreamOffset>(record.seqNum),
|
|
140
|
+
record.body,
|
|
141
|
+
record.timestamp.getTime(),
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
} catch (error: unknown) {
|
|
146
|
+
// s2-lite answers 416 to a read that starts at the tail or past it.
|
|
147
|
+
if (error instanceof RangeNotSatisfiableError) {
|
|
148
|
+
return { ok: true, value: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isUnknownStream(error)) {
|
|
152
|
+
return { ok: false, failure: { reason: 'unknown-stream' } };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function readTail(basin: S2Basin, stream: StreamName): Promise<StreamRead<StreamOffset>> {
|
|
160
|
+
try {
|
|
161
|
+
const response = await basin.stream(stream).checkTail();
|
|
162
|
+
|
|
163
|
+
return { ok: true, value: toTagged<StreamOffset>(response.tail.seqNum) };
|
|
164
|
+
} catch (error: unknown) {
|
|
165
|
+
if (isUnknownStream(error)) {
|
|
166
|
+
return { ok: false, failure: { reason: 'unknown-stream' } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { S2Error, SeqNumMismatchError } from '@s2-dev/streamstore';
|
|
2
|
+
import type { AppendBatchOutcome } from '../fact-log.ts';
|
|
3
|
+
|
|
4
|
+
// A client error from the server means the record was not stored; 408 and 429 ask for a retry. A
|
|
5
|
+
// refusal the port has no reason for, such as a bad token, returns null for the caller to throw.
|
|
6
|
+
export function findAppendOutcome(error: unknown): AppendBatchOutcome | null {
|
|
7
|
+
if (error instanceof SeqNumMismatchError) {
|
|
8
|
+
return { kind: 'rejected', reason: 'tail-mismatch' };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (isUnknownStream(error)) {
|
|
12
|
+
return { kind: 'rejected', reason: 'unknown-stream' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (isDefiniteRefusal(error)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { kind: 'unknown' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isUnknownStream(error: unknown): boolean {
|
|
23
|
+
return error instanceof S2Error && error.status === 404 && error.code === 'stream_not_found';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isDefiniteRefusal(error: unknown): boolean {
|
|
27
|
+
return (
|
|
28
|
+
error instanceof S2Error &&
|
|
29
|
+
error.origin === 'server' &&
|
|
30
|
+
error.status >= 400 &&
|
|
31
|
+
error.status < 500 &&
|
|
32
|
+
error.status !== 408 &&
|
|
33
|
+
error.status !== 429
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type * from './blob-store/blob-store.ts';
|
|
2
|
+
export { createGarageBlobStore } from './blob-store/garage/create-garage-blob-store.ts';
|
|
3
|
+
export { createLocalFolderBlobStore } from './blob-store/local-folder/create-local-folder-blob-store.ts';
|
|
4
|
+
export type * from './fact-log/fact-log.ts';
|
|
5
|
+
export { appendLimits } from './fact-log/check-append-batch.ts';
|
|
6
|
+
export { makeBatchAppender } from './fact-log/make-batch-appender.ts';
|
|
7
|
+
export type { AppendFailure, BatchAppender } from './fact-log/make-batch-appender.ts';
|
|
8
|
+
export { createMemoryFactLog } from './fact-log/memory/create-memory-fact-log.ts';
|
|
9
|
+
export type { MemoryFactLog } from './fact-log/memory/create-memory-fact-log.ts';
|
|
10
|
+
export { planAppends } from './fact-log/plan-appends.ts';
|
|
11
|
+
export { createS2FactLog } from './fact-log/s2/create-s2-fact-log.ts';
|
|
12
|
+
export { createIngest } from './receive/create-ingest.ts';
|
|
13
|
+
export type { Ingest, IngestOptions } from './receive/create-ingest.ts';
|
|
14
|
+
export type { PullPathName, PullRun, PullRunner } from './pull/make-pull-runner.ts';
|
|
15
|
+
export type { ResyncMarks } from './receive/resync-marks/create-resync-marks.ts';
|
|
16
|
+
export { createFakeSenderProof } from './sender-proof/fake/create-fake-sender-proof.ts';
|
|
17
|
+
export { createImpIdentitySenderProof } from './sender-proof/imp-identity/create-imp-identity-sender-proof.ts';
|
|
18
|
+
export { readImpIdentitySenderProof } from './sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
|
|
19
|
+
export { readImpIdentityTrust } from './sender-proof/imp-identity/read-imp-identity-trust.ts';
|
|
20
|
+
export type { ImpIdentityTrust } from './sender-proof/imp-identity/types.ts';
|
|
21
|
+
export { noSenderProof } from './sender-proof/none/no-sender-proof.ts';
|
|
22
|
+
export { serveIngest } from './serve/serve-ingest.ts';
|
|
23
|
+
export type * from './source-port/types.ts';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { CheckedDeployment, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
|
|
2
|
+
import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
3
|
+
import { findBatchProblem } from '../receive/find-batch-problem.ts';
|
|
4
|
+
import { planBatch } from '../receive/plan-batch.ts';
|
|
5
|
+
import type { ResyncMarks } from '../receive/resync-marks/create-resync-marks.ts';
|
|
6
|
+
import { writeBatch } from '../receive/write-batch.ts';
|
|
7
|
+
import type { StreamTurns } from '../receive/write-batch.ts';
|
|
8
|
+
|
|
9
|
+
export type PullPathName = 'live' | 'recovery' | 'backfill' | 'resync';
|
|
10
|
+
|
|
11
|
+
// `stored`: the batch is in the log, and the next run starts from `cursor`. `not-stored`: an append
|
|
12
|
+
// failed, so the next run starts from the same cursor. Any other answer is the pull's own.
|
|
13
|
+
export type PullRun =
|
|
14
|
+
| { kind: 'stored'; stored: number; cursor: string; isDone: boolean }
|
|
15
|
+
| { kind: 'not-stored' }
|
|
16
|
+
| { kind: 'refused'; reason: string }
|
|
17
|
+
| Exclude<Pulled, { kind: 'batch' }>;
|
|
18
|
+
|
|
19
|
+
export type PullRunner = (
|
|
20
|
+
source: string,
|
|
21
|
+
path: PullPathName,
|
|
22
|
+
cursor: string | null,
|
|
23
|
+
) => Promise<PullRun>;
|
|
24
|
+
|
|
25
|
+
interface PullRunnerDependencies {
|
|
26
|
+
deployment: CheckedDeployment;
|
|
27
|
+
|
|
28
|
+
// one value per name that a pull path's `env` lists
|
|
29
|
+
secrets: Readonly<Record<string, string>>;
|
|
30
|
+
append: BatchAppender;
|
|
31
|
+
turns: StreamTurns;
|
|
32
|
+
resyncMarks: ResyncMarks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// One run of one pull path: the pull, the envelope check, the appends, and then, for a resync pull,
|
|
36
|
+
// the removal of every mark it was given. A pull's fact is noted in no mark: a resynced fact ties
|
|
37
|
+
// with the body it replaces, and would mark its entity again on every run.
|
|
38
|
+
export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner {
|
|
39
|
+
return async (sourceName, pathName, cursor) => {
|
|
40
|
+
const source = dependencies.deployment.sources.find(
|
|
41
|
+
(candidate) => candidate.name === sourceName,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const path = source === undefined ? undefined : findPullPath(source, pathName);
|
|
45
|
+
|
|
46
|
+
if (source === undefined || path === undefined) {
|
|
47
|
+
return { kind: 'refused', reason: `the source ${sourceName} has no ${pathName} pull` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const marks = pathName === 'resync' ? dependencies.resyncMarks.list(source.name) : [];
|
|
51
|
+
const env: Record<string, string> = {};
|
|
52
|
+
|
|
53
|
+
for (const name of path.env ?? []) {
|
|
54
|
+
env[name] = dependencies.secrets[name] ?? '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pulled = await path.pull(cursor, { env, marks });
|
|
58
|
+
|
|
59
|
+
if (pulled.kind !== 'batch') {
|
|
60
|
+
return pulled;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const problem = findBatchProblem(source, pulled.batch);
|
|
64
|
+
|
|
65
|
+
if (problem !== undefined) {
|
|
66
|
+
return { kind: 'refused', reason: problem };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pulled.batch.facts.some((pending) => pending.blobs.length > 0)) {
|
|
70
|
+
return { kind: 'refused', reason: 'ingest has no blob store' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const plan = planBatch(pulled.batch);
|
|
74
|
+
|
|
75
|
+
if (!plan.ok) {
|
|
76
|
+
return { kind: 'refused', reason: 'one fact is larger than one append of the log takes' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const outcome = await writeBatch(plan.value, dependencies);
|
|
80
|
+
|
|
81
|
+
if (outcome !== 'stored') {
|
|
82
|
+
return outcome === 'not-stored'
|
|
83
|
+
? { kind: 'not-stored' }
|
|
84
|
+
: { kind: 'refused', reason: 'the log refused the batch' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const mark of marks) {
|
|
88
|
+
dependencies.resyncMarks.remove(source.name, mark.entity);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
kind: 'stored',
|
|
93
|
+
stored: pulled.batch.facts.length,
|
|
94
|
+
cursor: pulled.cursor,
|
|
95
|
+
isDone: pulled.isDone,
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findPullPath(source: SourceDefinition, pathName: PullPathName): PullPath | undefined {
|
|
101
|
+
const path = source[pathName];
|
|
102
|
+
|
|
103
|
+
return path?.kind === 'pull' ? path : undefined;
|
|
104
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Every failure body is `{"error": "<reason>"}`, on every path.
|
|
2
|
+
export function buildFailureAnswer(status: number, reason: string): Response {
|
|
3
|
+
return Response.json({ error: reason }, { status });
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// 503 means "send the same delivery again, unchanged": every sender of v1 does that.
|
|
7
|
+
export function buildRetryAnswer(): Response {
|
|
8
|
+
return Response.json(
|
|
9
|
+
{ error: 'the batch is not stored, send it again' },
|
|
10
|
+
{ status: 503, headers: { 'retry-after': '1' } },
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReceivePath } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
|
|
4
|
+
// 'status': 200 {"status":"stored","stored":<n>}, or 202 {"status":"ignored"}. 'empty': 200 {} for
|
|
5
|
+
// both, which an OTLP exporter expects.
|
|
6
|
+
export function buildSuccessAnswer(
|
|
7
|
+
path: ReceivePath,
|
|
8
|
+
outcome: 'stored' | 'ignored',
|
|
9
|
+
count: number,
|
|
10
|
+
): Response {
|
|
11
|
+
return match(path.answer ?? 'status')
|
|
12
|
+
.with('empty', () => Response.json({}, { status: 200 }))
|
|
13
|
+
.with('status', () =>
|
|
14
|
+
outcome === 'stored'
|
|
15
|
+
? Response.json({ status: 'stored', stored: count }, { status: 200 })
|
|
16
|
+
: Response.json({ status: 'ignored' }, { status: 202 }),
|
|
17
|
+
)
|
|
18
|
+
.exhaustive();
|
|
19
|
+
}
|