@pepys/ingest 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/ingest",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "pepys ingest: the receive routes, the fact log and the blob store",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,9 +21,9 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@impz/client": "7.0.0",
25
- "@pepys/kit": "0.4.2",
26
- "@pepys/schema": "0.4.2",
24
+ "@impz/client": "7.2.0",
25
+ "@pepys/kit": "0.4.3",
26
+ "@pepys/schema": "0.4.3",
27
27
  "@s2-dev/streamstore": "0.30.0",
28
28
  "@zgeoff/std": "0.4.3",
29
29
  "tiny-invariant": "1.3.3",
@@ -23,7 +23,7 @@ export function isUnknownStream(error: unknown): boolean {
23
23
  return error instanceof S2Error && error.status === 404 && error.code === 'stream_not_found';
24
24
  }
25
25
 
26
- function isDefiniteRefusal(error: unknown): boolean {
26
+ export function isDefiniteRefusal(error: unknown): boolean {
27
27
  return (
28
28
  error instanceof S2Error &&
29
29
  error.origin === 'server' &&
@@ -0,0 +1,23 @@
1
+ import { S2Error } from '@s2-dev/streamstore';
2
+ import { isDefiniteRefusal } from '../fact-log/s2/find-append-outcome.ts';
3
+
4
+ export type StartStep = { kind: 'retry'; delayMs: number } | { kind: 'stop' };
5
+
6
+ const firstDelayMs = 200;
7
+ const maxDelayMs = 30_000;
8
+
9
+ // the failures in a row after which the start ends, as the host's `up` does
10
+ const startRetryLimit = 10;
11
+
12
+ // What the start does after the fact log failed it `failures` times in a row. A failure that a later
13
+ // try can pass, such as a refused connection or a 5xx, waits a delay that doubles from 200 ms up to
14
+ // 30 s. A refusal of the request itself, such as a bad token, fails the same way each time.
15
+ export function pickStartStep(error: unknown, failures: number): StartStep {
16
+ const canPass = error instanceof S2Error && !isDefiniteRefusal(error);
17
+
18
+ if (!canPass || failures >= startRetryLimit) {
19
+ return { kind: 'stop' };
20
+ }
21
+
22
+ return { kind: 'retry', delayMs: Math.min(firstDelayMs * 2 ** (failures - 1), maxDelayMs) };
23
+ }
@@ -0,0 +1,52 @@
1
+ import type { Logger } from '@pepys/kit';
2
+ import { pickStartStep } from './pick-start-step.ts';
3
+
4
+ interface WaitOptions {
5
+ signal: AbortSignal;
6
+ }
7
+
8
+ interface StartRetry {
9
+ // `setTimeout` of `node:timers/promises`: settles after the delay, and rejects on an abort
10
+ wait: (delayMs: number, value: undefined, options: WaitOptions) => Promise<unknown>;
11
+
12
+ // aborts when ingest stops before its start ends
13
+ signal: AbortSignal;
14
+ logger: Logger;
15
+ }
16
+
17
+ export type StartRun<Value> =
18
+ | { kind: 'started'; value: Value }
19
+ | { kind: 'stopped' }
20
+ | { kind: 'failed'; error: unknown };
21
+
22
+ // Runs the part of the start that reads and writes the fact log, and runs it again after each
23
+ // failure that a later try can pass.
24
+ export async function runStartWithRetry<Value>(
25
+ start: () => Promise<Value>,
26
+ retry: StartRetry,
27
+ ): Promise<StartRun<Value>> {
28
+ let failures = 0;
29
+
30
+ while (!retry.signal.aborted) {
31
+ try {
32
+ return { kind: 'started', value: await start() };
33
+ } catch (error: unknown) {
34
+ failures += 1;
35
+
36
+ const step = pickStartStep(error, failures);
37
+
38
+ if (step.kind === 'stop') {
39
+ return { kind: 'failed', error };
40
+ }
41
+
42
+ retry.logger.error(
43
+ `the fact log failed the start of ingest, which tries again in ${step.delayMs} ms`,
44
+ error,
45
+ );
46
+
47
+ await retry.wait(step.delayMs, undefined, { signal: retry.signal }).catch(() => {});
48
+ }
49
+ }
50
+
51
+ return { kind: 'stopped' };
52
+ }
@@ -1,3 +1,4 @@
1
+ import { setTimeout as wait } from 'node:timers/promises';
1
2
  import { checkDeployment, createLogger, listReceiveRoutes } from '@pepys/kit';
2
3
  import type { Deployment, SourceDefinition, StreamName } from '@pepys/kit';
3
4
  import { makeShutdownHandler } from '@pepys/schema';
@@ -10,6 +11,7 @@ import { formatPullStart } from '../pull/cursors/format-pull-start.ts';
10
11
  import { readSavedCursors } from '../pull/cursors/read-saved-cursors.ts';
11
12
  import { createIngest } from '../receive/create-ingest.ts';
12
13
  import { readImpIdentitySenderProof } from '../sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
14
+ import { runStartWithRetry } from './run-start-with-retry.ts';
13
15
  import { startPullTimers } from './start-pull-timers.ts';
14
16
 
15
17
  interface ServeIngestOptions {
@@ -18,11 +20,14 @@ interface ServeIngestOptions {
18
20
 
19
21
  // false leaves SIGINT and SIGTERM to the caller, who stops ingest through the returned function
20
22
  exitOnSignal?: boolean;
23
+
24
+ // aborts a start that still waits for the fact log, for a caller that owns the stop signals
25
+ signal?: AbortSignal;
21
26
  }
22
27
 
23
- // The ingest process of a deployment. It exits 1 at start on a failed deployment check, on any
24
- // missing variable, or on a trust folder it cannot load, so no route ever runs with an empty secret.
25
- // A path whose trust documents or audience are unset starts, and refuses every delivery.
28
+ // The ingest process of a deployment. It exits 1 at start on a failed deployment check, a missing
29
+ // variable, or a trust folder it cannot load, so no route ever runs with an empty secret. A stop
30
+ // before ingest listens ends the start, and the returned function then stops nothing.
26
31
  export async function serveIngest(options: ServeIngestOptions): Promise<() => Promise<void>> {
27
32
  const logger = createLogger(process.stderr);
28
33
  const checked = checkDeployment(options.deployment);
@@ -52,14 +57,62 @@ export async function serveIngest(options: ServeIngestOptions): Promise<() => Pr
52
57
  process.exit(1);
53
58
  }
54
59
 
60
+ const starting = new AbortController();
61
+
62
+ let stop = (): Promise<void> => {
63
+ starting.abort();
64
+
65
+ return Promise.resolve();
66
+ };
67
+
68
+ if (options.exitOnSignal !== false) {
69
+ const shutdown = makeShutdownHandler({
70
+ stop: () => stop(),
71
+ logError: (message, error) => {
72
+ logger.error(message, error);
73
+ },
74
+ exit: (code) => process.exit(code),
75
+ });
76
+
77
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
78
+ process.on(signal, () => {
79
+ void shutdown();
80
+ });
81
+ }
82
+ }
83
+
55
84
  // The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
56
- const log = await createS2FactLog({
57
- ...config.value.s2,
58
- streams: [...collectStreams(sources), cursorStream],
59
- logger,
60
- });
85
+ const started = await runStartWithRetry(
86
+ async () => {
87
+ const log = await createS2FactLog({
88
+ ...config.value.s2,
89
+ streams: [...collectStreams(sources), cursorStream],
90
+ logger,
91
+ });
92
+
93
+ return { log, pulls: await readSavedCursors(log, collectTimedPulls(sources)) };
94
+ },
95
+ {
96
+ wait,
97
+ signal:
98
+ options.signal === undefined
99
+ ? starting.signal
100
+ : AbortSignal.any([starting.signal, options.signal]),
101
+ logger,
102
+ },
103
+ );
61
104
 
62
- const pulls = await readSavedCursors(log, collectTimedPulls(sources));
105
+ if (started.kind === 'stopped') {
106
+ return () => Promise.resolve();
107
+ }
108
+
109
+ if (started.kind === 'failed') {
110
+ logger.error('the fact log failed the start of ingest', started.error);
111
+ process.exit(1);
112
+ }
113
+
114
+ const log = started.value.log;
115
+ const pulls = started.value.pulls;
63
116
 
64
117
  const ingest = createIngest({
65
118
  deployment: checked.value,
@@ -90,30 +143,12 @@ export async function serveIngest(options: ServeIngestOptions): Promise<() => Pr
90
143
 
91
144
  console.log(`ingest listening on ${server.hostname}:${server.port}`);
92
145
 
93
- const stop = async (): Promise<void> => {
146
+ stop = async (): Promise<void> => {
94
147
  await stopPulls();
95
148
 
96
149
  await server.stop();
97
150
  };
98
151
 
99
- if (options.exitOnSignal === false) {
100
- return stop;
101
- }
102
-
103
- const shutdown = makeShutdownHandler({
104
- stop,
105
- logError: (message, error) => {
106
- logger.error(message, error);
107
- },
108
- exit: (code) => process.exit(code),
109
- });
110
-
111
- for (const signal of ['SIGINT', 'SIGTERM'] as const) {
112
- process.on(signal, () => {
113
- void shutdown();
114
- });
115
- }
116
-
117
152
  return stop;
118
153
  }
119
154