@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.
Files changed (33) hide show
  1. package/package.json +3 -3
  2. package/src/fact-log/make-batch-appender.ts +43 -128
  3. package/src/fact-log/memory/create-memory-fact-log.ts +1 -1
  4. package/src/fact-log/pick-attempt-step.ts +53 -0
  5. package/src/fact-log/pick-retry-step.ts +47 -0
  6. package/src/fact-log/pick-settled-attempt.ts +30 -0
  7. package/src/fact-log/read-run.ts +34 -0
  8. package/src/fact-log/s2/create-s2-fact-log.ts +17 -6
  9. package/src/index.ts +3 -3
  10. package/src/pull/collect-timed-pulls.ts +23 -0
  11. package/src/pull/cursors/build-cursor-entity.ts +8 -0
  12. package/src/pull/cursors/build-cursor-record.ts +32 -0
  13. package/src/pull/cursors/cursor-stream.ts +6 -0
  14. package/src/pull/cursors/format-pull-start.ts +13 -0
  15. package/src/pull/cursors/read-saved-cursors.ts +55 -0
  16. package/src/pull/make-pull-runner.ts +98 -51
  17. package/src/pull/with-source-lock.ts +76 -0
  18. package/src/receive/answers/build-failure-answer.ts +2 -1
  19. package/src/receive/create-ingest.ts +10 -3
  20. package/src/receive/make-receive-handler.ts +21 -37
  21. package/src/receive/pick-received-step.ts +56 -0
  22. package/src/receive/write-batch.ts +8 -6
  23. package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +8 -2
  24. package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +5 -1
  25. package/src/serve/serve-ingest.ts +43 -14
  26. package/src/serve/start-pull-timers.ts +47 -39
  27. package/src/{receive → utils}/find-batch-problem.ts +4 -1
  28. package/src/utils/pick-batch-plan.ts +37 -0
  29. package/src/{receive → utils}/plan-batch.ts +1 -1
  30. package/src/source-port/types.ts +0 -64
  31. /package/src/{fact-log → utils}/check-append-batch.ts +0 -0
  32. /package/src/{fact-log → utils}/parse-stored-body.ts +0 -0
  33. /package/src/{fact-log → utils}/plan-appends.ts +0 -0
@@ -1,9 +1,13 @@
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';
5
5
  import { readIngestConfig } from '../config/read-ingest-config.ts';
6
6
  import { createS2FactLog } from '../fact-log/s2/create-s2-fact-log.ts';
7
+ import { collectTimedPulls } from '../pull/collect-timed-pulls.ts';
8
+ import { cursorStream } from '../pull/cursors/cursor-stream.ts';
9
+ import { formatPullStart } from '../pull/cursors/format-pull-start.ts';
10
+ import { readSavedCursors } from '../pull/cursors/read-saved-cursors.ts';
7
11
  import { createIngest } from '../receive/create-ingest.ts';
8
12
  import { readImpIdentitySenderProof } from '../sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
9
13
  import { startPullTimers } from './start-pull-timers.ts';
@@ -11,16 +15,20 @@ import { startPullTimers } from './start-pull-timers.ts';
11
15
  interface ServeIngestOptions {
12
16
  deployment: Deployment;
13
17
  environment: Readonly<Record<string, string | undefined>>;
18
+
19
+ // false leaves SIGINT and SIGTERM to the caller, who stops ingest through the returned function
20
+ exitOnSignal?: boolean;
14
21
  }
15
22
 
16
23
  // The ingest process of a deployment. It exits 1 at start on a failed deployment check, on any
17
24
  // missing variable, or on a trust folder it cannot load, so no route ever runs with an empty secret.
18
25
  // A path whose trust documents or audience are unset starts, and refuses every delivery.
19
- export async function serveIngest(options: ServeIngestOptions): Promise<void> {
26
+ export async function serveIngest(options: ServeIngestOptions): Promise<() => Promise<void>> {
27
+ const logger = createLogger(process.stderr);
20
28
  const checked = checkDeployment(options.deployment);
21
29
 
22
30
  if (!checked.ok) {
23
- console.error('the deployment fails its check', JSON.stringify(checked.failure));
31
+ logger.error('the deployment fails its check', checked.failure);
24
32
  process.exit(1);
25
33
  }
26
34
 
@@ -28,7 +36,7 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
28
36
  const config = readIngestConfig(options.environment, collectSecretNames(sources));
29
37
 
30
38
  if (!config.ok) {
31
- console.error(config.failure.join('\n'));
39
+ logger.error('the ingest config is incomplete', config.failure);
32
40
  process.exit(1);
33
41
  }
34
42
 
@@ -36,15 +44,22 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
36
44
  sources,
37
45
  environment: options.environment,
38
46
  now: () => Date.now(),
47
+ logger,
39
48
  });
40
49
 
41
50
  if (!senderProof.ok) {
42
- console.error(senderProof.failure);
51
+ logger.error('the sender proof cannot start', senderProof.failure);
43
52
  process.exit(1);
44
53
  }
45
54
 
46
55
  // The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
47
- const log = await createS2FactLog({ ...config.value.s2, streams: collectStreams(sources) });
56
+ const log = await createS2FactLog({
57
+ ...config.value.s2,
58
+ streams: [...collectStreams(sources), cursorStream],
59
+ logger,
60
+ });
61
+
62
+ const pulls = await readSavedCursors(log, collectTimedPulls(sources));
48
63
 
49
64
  const ingest = createIngest({
50
65
  deployment: checked.value,
@@ -53,30 +68,42 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
53
68
  senderProof: senderProof.value,
54
69
  appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
55
70
  now: () => Date.now(),
71
+ logger,
56
72
  });
57
73
 
58
74
  for (const route of listReceiveRoutes(options.deployment)) {
59
75
  console.log(`route ${route.route}`, JSON.stringify(route));
60
76
  }
61
77
 
62
- const stopPulls = startPullTimers(sources, ingest.runPull);
63
-
78
+ // The server starts first, so a failed listen leaves no timer behind.
64
79
  const server = Bun.serve({
65
80
  hostname: config.value.host,
66
81
  port: config.value.port,
67
82
  fetch: ingest.handleRequest,
68
83
  });
69
84
 
85
+ for (const pull of pulls) {
86
+ console.log(formatPullStart(pull));
87
+ }
88
+
89
+ const stopPulls = startPullTimers(pulls, ingest.runPull, logger);
90
+
70
91
  console.log(`ingest listening on ${server.hostname}:${server.port}`);
71
92
 
72
- const shutdown = makeShutdownHandler({
73
- stop: () => {
74
- stopPulls();
93
+ const stop = async (): Promise<void> => {
94
+ await stopPulls();
75
95
 
76
- return server.stop();
77
- },
96
+ await server.stop();
97
+ };
98
+
99
+ if (options.exitOnSignal === false) {
100
+ return stop;
101
+ }
102
+
103
+ const shutdown = makeShutdownHandler({
104
+ stop,
78
105
  logError: (message, error) => {
79
- console.error(message, error);
106
+ logger.error(message, error);
80
107
  },
81
108
  exit: (code) => process.exit(code),
82
109
  });
@@ -86,6 +113,8 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
86
113
  void shutdown();
87
114
  });
88
115
  }
116
+
117
+ return stop;
89
118
  }
90
119
 
91
120
  // every receive path's secret, and every name a pull path's `env` lists
@@ -1,60 +1,68 @@
1
- import type { SourceDefinition } from '@pepys/kit';
1
+ import type { Logger } from '@pepys/kit';
2
2
  import { match } from 'ts-pattern';
3
- import type { PullPathName, PullRun, PullRunner } from '../pull/make-pull-runner.ts';
3
+ import type { ResumedPull } from '../pull/cursors/read-saved-cursors.ts';
4
+ import type { PullRun, PullRunner } from '../pull/make-pull-runner.ts';
4
5
 
5
- const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
6
-
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.
6
+ // One timer per pull, whose first run starts from the pull's saved cursor. A timer starts no run
7
+ // while its last one waits for its source or runs. Returns the function that stops the timers: it
8
+ // aborts each run, waiting or in flight, and settles once every run has ended.
9
9
  export function startPullTimers(
10
- sources: readonly SourceDefinition[],
10
+ pulls: readonly ResumedPull[],
11
11
  runPull: PullRunner,
12
- ): () => void {
13
- const timers = sources.flatMap((source) =>
14
- pullPathNames.flatMap((pathName) => {
15
- const path = source[pathName];
12
+ logger: Logger,
13
+ ): () => Promise<void> {
14
+ const controller = new AbortController();
15
+ const activeRuns = new Set<Promise<void>>();
16
+
17
+ const timers = pulls.map((pull) => {
18
+ let cursor = pull.cursor;
19
+ let isRunning = false;
16
20
 
17
- if (path?.kind !== 'pull' || path.intervalMs === null) {
18
- return [];
21
+ const runOnce = async (): Promise<void> => {
22
+ if (isRunning || controller.signal.aborted) {
23
+ return;
19
24
  }
20
25
 
21
- let cursor: string | null = null;
22
- let isRunning = false;
26
+ isRunning = true;
23
27
 
24
- const runOnce = async (): Promise<void> => {
25
- if (isRunning) {
26
- return;
27
- }
28
+ const ended = Promise.withResolvers<void>();
28
29
 
29
- isRunning = true;
30
+ activeRuns.add(ended.promise);
30
31
 
31
- try {
32
- const run = await runPull(source.name, pathName, cursor);
32
+ try {
33
+ const run = await runPull(pull.source, pull.path, cursor, controller.signal);
33
34
 
34
- cursor = pickNextCursor(run, cursor);
35
+ cursor = pickNextCursor(run, cursor);
35
36
 
36
- if (run.kind !== 'stored') {
37
- console.error(`the ${pathName} pull of ${source.name} did not store`, run);
38
- }
39
- } catch (error: unknown) {
40
- console.error(`the ${pathName} pull of ${source.name} threw`, error);
41
- } finally {
42
- isRunning = false;
37
+ // a run that the stop cut short is no failure
38
+ if (run.kind !== 'stored' && !controller.signal.aborted) {
39
+ logger.error(`the ${pull.path} pull of ${pull.source} did not store`, run);
40
+ }
41
+ } catch (error: unknown) {
42
+ if (!controller.signal.aborted) {
43
+ logger.error(`the ${pull.path} pull of ${pull.source} threw`, error);
43
44
  }
44
- };
45
+ } finally {
46
+ isRunning = false;
45
47
 
46
- return [
47
- setInterval(() => {
48
- void runOnce();
49
- }, path.intervalMs),
50
- ];
51
- }),
52
- );
48
+ activeRuns.delete(ended.promise);
49
+ ended.resolve();
50
+ }
51
+ };
52
+
53
+ return setInterval(() => {
54
+ void runOnce();
55
+ }, pull.intervalMs);
56
+ });
53
57
 
54
- return () => {
58
+ return async () => {
55
59
  for (const timer of timers) {
56
60
  clearInterval(timer);
57
61
  }
62
+
63
+ controller.abort();
64
+
65
+ await Promise.all(activeRuns);
58
66
  };
59
67
  }
60
68
 
@@ -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(source: SourceContext, batch: Batch): string | undefined {
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()) {
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Batch, FactEnvelope, Result, StreamName } from '@pepys/kit';
2
- import { planAppends } from '../fact-log/plan-appends.ts';
2
+ import { planAppends } from './plan-appends.ts';
3
3
 
4
4
  export type BatchPlan = ReadonlyMap<StreamName, readonly (readonly FactEnvelope<unknown>[])[]>;
5
5
 
@@ -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
- }
File without changes
File without changes
File without changes