@wringer/stream 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/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/append-whole-text.d.ts +1 -0
- package/dist/build-file-step.d.ts +16 -0
- package/dist/build-stream-slice.d.ts +12 -0
- package/dist/collect-lines.d.ts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/pick-command-stop-delay.d.ts +6 -0
- package/dist/pick-file-read.d.ts +10 -0
- package/dist/read-file-from.d.ts +11 -0
- package/dist/read-stream-slice.d.ts +8 -0
- package/dist/record-writer-machine.d.ts +65 -0
- package/dist/source-phase-machine.d.ts +21 -0
- package/dist/start-stream-recorder.d.ts +39 -0
- package/dist/stream.d.ts +29 -0
- package/dist/types.d.ts +2 -0
- package/dist/wait-or-abort.d.ts +1 -0
- package/package.json +47 -4
- package/src/append-whole-text.ts +30 -0
- package/src/build-file-step.ts +39 -0
- package/src/build-stream-slice.ts +34 -0
- package/src/collect-lines.ts +13 -0
- package/src/index.ts +5 -0
- package/src/pick-command-stop-delay.ts +23 -0
- package/src/pick-file-read.ts +22 -0
- package/src/read-file-from.ts +54 -0
- package/src/read-stream-slice.ts +84 -0
- package/src/record-writer-machine.ts +185 -0
- package/src/source-phase-machine.ts +132 -0
- package/src/start-stream-recorder.ts +354 -0
- package/src/stream.ts +175 -0
- package/src/types.ts +4 -0
- package/src/wait-or-abort.ts +7 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
2
|
+
import { WringerError, isErrorCode } from '@wringer/core';
|
|
3
|
+
import { buildStreamSlice } from './build-stream-slice.ts';
|
|
4
|
+
import type { StreamSlice } from './build-stream-slice.ts';
|
|
5
|
+
import type { OpenFile } from './types.ts';
|
|
6
|
+
|
|
7
|
+
export interface StreamSliceRequest {
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly from: number;
|
|
10
|
+
readonly maxBytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Reads whole records from the byte offset `from`, up to `maxBytes`. A file the machine refuses to
|
|
14
|
+
// read, such as a folder at the stream's path, is a fault of the environment, not of wringer.
|
|
15
|
+
export async function readStreamSlice(
|
|
16
|
+
request: StreamSliceRequest,
|
|
17
|
+
openFile: OpenFile,
|
|
18
|
+
): Promise<StreamSlice> {
|
|
19
|
+
try {
|
|
20
|
+
return await readSlice(request, openFile);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new WringerError('environment', {
|
|
23
|
+
message: `The stream file ${request.path} could not be read: ${String(error)}`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The recorder caps each record well under the smallest slice, so a full slice always ends on a
|
|
29
|
+
// line end. A cursor past the end of the file comes from an older run, so the read starts over at 0.
|
|
30
|
+
async function readSlice(request: StreamSliceRequest, openFile: OpenFile): Promise<StreamSlice> {
|
|
31
|
+
const handle = await openStreamFile(openFile, request.path);
|
|
32
|
+
|
|
33
|
+
if (handle === null) {
|
|
34
|
+
return { records: [], cursor: 0, more: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const info = await handle.stat();
|
|
39
|
+
|
|
40
|
+
const size = info.size;
|
|
41
|
+
const requested = request.from > size ? 0 : request.from;
|
|
42
|
+
|
|
43
|
+
const from = await readLineStart(handle, requested, size);
|
|
44
|
+
|
|
45
|
+
const length = Math.min(request.maxBytes, size - from);
|
|
46
|
+
const buffer = Buffer.alloc(length);
|
|
47
|
+
|
|
48
|
+
await handle.read(buffer, 0, length, from);
|
|
49
|
+
|
|
50
|
+
return buildStreamSlice(buffer, from, size);
|
|
51
|
+
} finally {
|
|
52
|
+
await handle.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A stream that has recorded nothing yet has no file. Any other failure, such as EACCES, rejects.
|
|
57
|
+
async function openStreamFile(openFile: OpenFile, path: string): Promise<FileHandle | null> {
|
|
58
|
+
try {
|
|
59
|
+
return await openFile(path);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (isErrorCode(error, 'ENOENT')) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const newline = 0x0a;
|
|
70
|
+
|
|
71
|
+
// A cursor a caller typed may land inside a record; the read then starts at the next one.
|
|
72
|
+
async function readLineStart(handle: FileHandle, from: number, size: number): Promise<number> {
|
|
73
|
+
if (from === 0) {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const buffer = Buffer.alloc(Math.min(size - from + 1, 64 * 1024));
|
|
78
|
+
|
|
79
|
+
await handle.read(buffer, 0, buffer.length, from - 1);
|
|
80
|
+
|
|
81
|
+
const index = buffer.indexOf(newline);
|
|
82
|
+
|
|
83
|
+
return index === -1 ? size : from + index;
|
|
84
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { WringerError } from '@wringer/core';
|
|
2
|
+
import { assign, fromPromise, setup } from 'xstate';
|
|
3
|
+
|
|
4
|
+
interface WriterInput {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly pendingLimit: number;
|
|
7
|
+
readonly now: () => Date;
|
|
8
|
+
readonly write: (text: string) => Promise<void>;
|
|
9
|
+
readonly onError: (error: unknown) => void;
|
|
10
|
+
|
|
11
|
+
// Called when a write succeeds after one that failed.
|
|
12
|
+
readonly onRecovered: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface WriterContext extends WriterInput {
|
|
16
|
+
readonly pending: readonly string[];
|
|
17
|
+
readonly batch: readonly string[];
|
|
18
|
+
readonly batchDropped: number;
|
|
19
|
+
readonly dropped: number;
|
|
20
|
+
readonly failure: string | null;
|
|
21
|
+
readonly drains: readonly PromiseWithResolvers<null>[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type WriterEvent =
|
|
25
|
+
| { readonly type: 'add'; readonly record: string }
|
|
26
|
+
| { readonly type: 'drain'; readonly settle: PromiseWithResolvers<null> };
|
|
27
|
+
|
|
28
|
+
const writerTypes: { context?: WriterContext; events?: WriterEvent; input?: WriterInput } = {};
|
|
29
|
+
|
|
30
|
+
interface BatchInput {
|
|
31
|
+
readonly write: WriterInput['write'];
|
|
32
|
+
readonly lines: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Appends the records that arrived while the last write ran in one write, so a fast source costs
|
|
36
|
+
// one write per batch and a bounded amount of memory. A batch that fails to write goes back to the
|
|
37
|
+
// front of the queue: the next record retries it, and a drain retries it once, then rejects.
|
|
38
|
+
export const recordWriterMachine = setup({
|
|
39
|
+
types: writerTypes,
|
|
40
|
+
actors: {
|
|
41
|
+
writeBatch: fromPromise<null, BatchInput>(async (args) => {
|
|
42
|
+
await args.input.write(`${args.input.lines.join('\n')}\n`);
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
guards: {
|
|
48
|
+
hasPending: (args) => args.context.pending.length > 0 || args.context.dropped > 0,
|
|
49
|
+
},
|
|
50
|
+
actions: {
|
|
51
|
+
enqueue: assign((args) => {
|
|
52
|
+
if (args.event.type !== 'add') {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return args.context.pending.length >= args.context.pendingLimit
|
|
57
|
+
? { dropped: args.context.dropped + 1 }
|
|
58
|
+
: { pending: [...args.context.pending, args.event.record] };
|
|
59
|
+
}),
|
|
60
|
+
takeBatch: assign((args) => ({
|
|
61
|
+
batch: args.context.pending,
|
|
62
|
+
batchDropped: args.context.dropped,
|
|
63
|
+
pending: [],
|
|
64
|
+
dropped: 0,
|
|
65
|
+
})),
|
|
66
|
+
keepBatch: assign((args) => {
|
|
67
|
+
const kept = [...args.context.batch, ...args.context.pending];
|
|
68
|
+
const limit = args.context.pendingLimit;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
batch: [],
|
|
72
|
+
batchDropped: 0,
|
|
73
|
+
pending: kept.slice(0, limit),
|
|
74
|
+
dropped:
|
|
75
|
+
args.context.batchDropped + args.context.dropped + Math.max(kept.length - limit, 0),
|
|
76
|
+
};
|
|
77
|
+
}),
|
|
78
|
+
keepDrain: assign({
|
|
79
|
+
drains: (args) =>
|
|
80
|
+
args.event.type === 'drain'
|
|
81
|
+
? [...args.context.drains, args.event.settle]
|
|
82
|
+
: args.context.drains,
|
|
83
|
+
}),
|
|
84
|
+
resolveDrains: assign((args) => {
|
|
85
|
+
for (const drain of args.context.drains) {
|
|
86
|
+
drain.resolve(null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { drains: [] };
|
|
90
|
+
}),
|
|
91
|
+
reportRecovery: (args) => {
|
|
92
|
+
if (args.context.failure !== null) {
|
|
93
|
+
args.context.onRecovered();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
rejectDrains: assign((args) => {
|
|
97
|
+
const error = new WringerError('environment', {
|
|
98
|
+
message: `${args.context.path} kept ${args.context.pending.length} records the recorder could not write: ${args.context.failure ?? 'the write failed'}`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for (const drain of args.context.drains) {
|
|
102
|
+
drain.reject(error);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { drains: [] };
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
}).createMachine({
|
|
109
|
+
context: (args) => ({
|
|
110
|
+
...args.input,
|
|
111
|
+
pending: [],
|
|
112
|
+
batch: [],
|
|
113
|
+
batchDropped: 0,
|
|
114
|
+
dropped: 0,
|
|
115
|
+
failure: null,
|
|
116
|
+
drains: [],
|
|
117
|
+
}),
|
|
118
|
+
initial: 'idle',
|
|
119
|
+
states: {
|
|
120
|
+
idle: {
|
|
121
|
+
entry: 'resolveDrains',
|
|
122
|
+
on: {
|
|
123
|
+
add: { target: 'writing', actions: 'enqueue' },
|
|
124
|
+
drain: { actions: ['keepDrain', 'resolveDrains'] },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
writing: {
|
|
128
|
+
entry: 'takeBatch',
|
|
129
|
+
invoke: {
|
|
130
|
+
// oxlint-disable-next-line zgeoff/no-abbreviated-names -- xstate names this key `src`
|
|
131
|
+
src: 'writeBatch',
|
|
132
|
+
input: (args) => ({
|
|
133
|
+
write: args.context.write,
|
|
134
|
+
lines: [
|
|
135
|
+
...args.context.batch,
|
|
136
|
+
...buildDropNotice(args.context.batchDropped, args.context.now),
|
|
137
|
+
],
|
|
138
|
+
}),
|
|
139
|
+
onDone: [
|
|
140
|
+
{
|
|
141
|
+
guard: 'hasPending',
|
|
142
|
+
target: 'writing',
|
|
143
|
+
reenter: true,
|
|
144
|
+
actions: ['reportRecovery', assign({ batch: [], batchDropped: 0, failure: null })],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
target: 'idle',
|
|
148
|
+
actions: ['reportRecovery', assign({ batch: [], batchDropped: 0, failure: null })],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
onError: {
|
|
152
|
+
target: 'stalled',
|
|
153
|
+
actions: [
|
|
154
|
+
'keepBatch',
|
|
155
|
+
assign({
|
|
156
|
+
failure: (args) =>
|
|
157
|
+
args.event.error instanceof Error ? args.event.error.message : 'the write failed',
|
|
158
|
+
}),
|
|
159
|
+
(args) => {
|
|
160
|
+
args.context.onError(args.event.error);
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
on: { add: { actions: 'enqueue' }, drain: { actions: 'keepDrain' } },
|
|
166
|
+
},
|
|
167
|
+
stalled: {
|
|
168
|
+
entry: 'rejectDrains',
|
|
169
|
+
on: {
|
|
170
|
+
add: { target: 'writing', actions: 'enqueue' },
|
|
171
|
+
drain: { target: 'writing', actions: 'keepDrain' },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
function buildDropNotice(dropped: number, now: () => Date): string[] {
|
|
178
|
+
if (dropped === 0) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const line = `[wringer] the recorder dropped ${dropped} lines while it caught up`;
|
|
183
|
+
|
|
184
|
+
return [JSON.stringify({ at: now().toISOString(), line })];
|
|
185
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { assign, setup } from 'xstate';
|
|
2
|
+
|
|
3
|
+
// A source's phase joins how its reading ended with whether its last write failed. A failed write
|
|
4
|
+
// shows as `failed` until a write succeeds, then the source shows how its reading stands again.
|
|
5
|
+
export type SourcePhase =
|
|
6
|
+
| 'running'
|
|
7
|
+
| 'running-unwritten'
|
|
8
|
+
| 'exited'
|
|
9
|
+
| 'exited-unwritten'
|
|
10
|
+
| 'failed'
|
|
11
|
+
| 'failed-unwritten';
|
|
12
|
+
|
|
13
|
+
export type SourceEventType = 'exit' | 'fail' | 'write-failed' | 'write-recovered';
|
|
14
|
+
|
|
15
|
+
// `detail` is why the reading ended for `exit` and `fail`, and why the write failed for
|
|
16
|
+
// `write-failed`.
|
|
17
|
+
export interface SourceEvent {
|
|
18
|
+
readonly type: SourceEventType;
|
|
19
|
+
readonly detail: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SourceDetails {
|
|
23
|
+
// Why the reading ended, once it has.
|
|
24
|
+
readonly reading: string | null;
|
|
25
|
+
readonly write: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SourceContext {
|
|
29
|
+
readonly phase: SourcePhase;
|
|
30
|
+
readonly details: SourceDetails;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sourceTypes: { context?: SourceContext; events?: SourceEvent } = {};
|
|
34
|
+
|
|
35
|
+
// The detail each event sets once it moves the source.
|
|
36
|
+
const eventDetails: Readonly<Record<SourceEventType, keyof SourceDetails>> = {
|
|
37
|
+
exit: 'reading',
|
|
38
|
+
fail: 'reading',
|
|
39
|
+
'write-failed': 'write',
|
|
40
|
+
'write-recovered': 'write',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Holds a source's phase and the details its status reports. An event the transition table refuses
|
|
44
|
+
// leaves both as they are, so the first reason a reading ended stays.
|
|
45
|
+
export const sourcePhaseMachine = setup({
|
|
46
|
+
types: sourceTypes,
|
|
47
|
+
actions: {
|
|
48
|
+
move: assign((args) => {
|
|
49
|
+
const next = pickSourcePhase(args.context.phase, args.event.type);
|
|
50
|
+
|
|
51
|
+
if (next === args.context.phase) {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
phase: next,
|
|
57
|
+
details: { ...args.context.details, [eventDetails[args.event.type]]: args.event.detail },
|
|
58
|
+
};
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
}).createMachine({
|
|
62
|
+
context: { phase: 'running', details: { reading: null, write: null } },
|
|
63
|
+
on: {
|
|
64
|
+
exit: { actions: 'move' },
|
|
65
|
+
fail: { actions: 'move' },
|
|
66
|
+
'write-failed': { actions: 'move' },
|
|
67
|
+
'write-recovered': { actions: 'move' },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The supervisor sends each source's status to the CLI, so every change of it goes through this
|
|
72
|
+
// table.
|
|
73
|
+
const sourceTransitions: Readonly<Record<SourcePhase, readonly SourcePhase[]>> = {
|
|
74
|
+
running: ['running-unwritten', 'exited', 'failed'],
|
|
75
|
+
'running-unwritten': ['running', 'exited-unwritten', 'failed-unwritten'],
|
|
76
|
+
exited: ['exited-unwritten'],
|
|
77
|
+
'exited-unwritten': ['exited'],
|
|
78
|
+
failed: ['failed-unwritten'],
|
|
79
|
+
'failed-unwritten': ['failed'],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function canMoveSource(from: SourcePhase, to: SourcePhase): boolean {
|
|
83
|
+
return sourceTransitions[from].includes(to);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The phase each event aims at; the table above decides whether the source may move there.
|
|
87
|
+
const eventTargets: Readonly<Record<SourcePhase, Readonly<Record<SourceEventType, SourcePhase>>>> =
|
|
88
|
+
{
|
|
89
|
+
running: {
|
|
90
|
+
exit: 'exited',
|
|
91
|
+
fail: 'failed',
|
|
92
|
+
'write-failed': 'running-unwritten',
|
|
93
|
+
'write-recovered': 'running',
|
|
94
|
+
},
|
|
95
|
+
'running-unwritten': {
|
|
96
|
+
exit: 'exited-unwritten',
|
|
97
|
+
fail: 'failed-unwritten',
|
|
98
|
+
'write-failed': 'running-unwritten',
|
|
99
|
+
'write-recovered': 'running',
|
|
100
|
+
},
|
|
101
|
+
exited: {
|
|
102
|
+
exit: 'exited',
|
|
103
|
+
fail: 'failed',
|
|
104
|
+
'write-failed': 'exited-unwritten',
|
|
105
|
+
'write-recovered': 'exited',
|
|
106
|
+
},
|
|
107
|
+
'exited-unwritten': {
|
|
108
|
+
exit: 'exited-unwritten',
|
|
109
|
+
fail: 'failed-unwritten',
|
|
110
|
+
'write-failed': 'exited-unwritten',
|
|
111
|
+
'write-recovered': 'exited',
|
|
112
|
+
},
|
|
113
|
+
failed: {
|
|
114
|
+
exit: 'exited',
|
|
115
|
+
fail: 'failed',
|
|
116
|
+
'write-failed': 'failed-unwritten',
|
|
117
|
+
'write-recovered': 'failed',
|
|
118
|
+
},
|
|
119
|
+
'failed-unwritten': {
|
|
120
|
+
exit: 'exited-unwritten',
|
|
121
|
+
fail: 'failed-unwritten',
|
|
122
|
+
'write-failed': 'failed-unwritten',
|
|
123
|
+
'write-recovered': 'failed',
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// An event the table refuses from this phase, such as an exit after a failure, leaves it as it is.
|
|
128
|
+
export function pickSourcePhase(phase: SourcePhase, event: SourceEventType): SourcePhase {
|
|
129
|
+
const target = eventTargets[phase][event];
|
|
130
|
+
|
|
131
|
+
return canMoveSource(phase, target) ? target : phase;
|
|
132
|
+
}
|