@genee/omp-opsx-addon 0.7.0 → 0.9.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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Worker process for `lib/concurrency-multiproc.test.ts` (tasks 7.2, spec
3
+ * Requirement「并发上限写入与跨进程单写者」).
4
+ *
5
+ * One worker == one OMP-like process running the *real* control loop
6
+ * (`ConcurrencyController` + the writer's `createLease` / read-merge-write)
7
+ * against a shared temp config root and a shared mock-Redis TCP server owned by
8
+ * the parent test.
9
+ *
10
+ * Synchronisation model — the parent fully drives the timeline:
11
+ *
12
+ * - stdin: one JSON command per line (`{ id, cmd, ... }`).
13
+ * - stdout: exactly one JSON reply per command, in order, tagged with the
14
+ * command's `id`.
15
+ * - the parent awaits each reply before sending the next command, so every
16
+ * ordering fact (who holds the lease, that a write has landed, that a 429
17
+ * was reported) is established by a request/response handshake instead of a
18
+ * sleep. Nothing in this file polls or waits on wall-clock time.
19
+ *
20
+ * Clock: the control loop's clock is *injected* (`now: () => clock`) and only
21
+ * moves when the parent asks for it (`tick.advanceMs`), so `onTick`'s
22
+ * absolute-time gate, the AIMD windows and the write gate all advance
23
+ * deterministically. The base (`init.startAt`) is the parent's real `Date.now()`
24
+ * so that the writer's `<configPath>.lock` staleness comparison (real file
25
+ * mtime vs. injected `now`) stays meaningful.
26
+ *
27
+ * Commands:
28
+ * - `init` – build the controller (ports: node fs, no model_perf, optional
29
+ * Redis TCP client, recording settings port). With
30
+ * `brokenFileLease` the file-lease `O_EXCL` create always fails
31
+ * with `EOPNOTSUPP` (the "atomic create unavailable" injection).
32
+ * - `tick` – advance the clock by `advanceMs` and run one `onTick()`,
33
+ * replying with the tick result, the writer identity, every
34
+ * provider's control state, the on-disk shared values, the
35
+ * settings payloads and the warnings produced since last reply.
36
+ * - `failure` – deliver one in-process explicit failure (① classifier) via
37
+ * `noteExplicitFailure`, exactly like the host's 429 path would.
38
+ * - `perf` – advance the synthetic `model_perf` counters by one traffic
39
+ * step, i.e. make the *next* evaluation window carry a
40
+ * generation sample (R1 observation evidence; without it a
41
+ * window is frozen and `L` never moves up).
42
+ */
43
+
44
+ import { TUNER_DEFAULTS } from './concurrency-tuner.js';
45
+ import type { BadEventKind, ModelPerfRow, TunerOptions } from './concurrency-tuner.js';
46
+ import {
47
+ createConcurrencyController,
48
+ createNodeFsPort,
49
+ type ConcurrencyController,
50
+ type ConcurrencyLeaseArgs,
51
+ type ConcurrencyLeaseFactory,
52
+ } from './concurrency-control.js';
53
+ import {
54
+ createLease,
55
+ nodeWriterFileSystem,
56
+ readManagedLimits,
57
+ type LeaseMode,
58
+ type SettingsPort,
59
+ type WriterFileSystemPort,
60
+ } from './concurrency-writer.js';
61
+ import { RedisClient } from './usage-redis-client.js';
62
+
63
+ // ── wire protocol ───────────────────────────────────────────────────────────
64
+
65
+ export interface MultiprocInitCommand {
66
+ id: number;
67
+ cmd: 'init';
68
+ /** Config root `config.yml`. */
69
+ configPath: string;
70
+ /** Managed providers (writer targets). */
71
+ providers: string[];
72
+ /** Lease owner id (`pid-<name>` from the parent). */
73
+ owner: string;
74
+ /** Injected clock base — the parent's real `Date.now()`. */
75
+ startAt: number;
76
+ /** Mock-Redis port, or `null` to run without the aggregation channel. */
77
+ redisPort: number | null;
78
+ /** Log directory the collector tails (empty in these tests). */
79
+ logDir: string;
80
+ tuner?: Partial<TunerOptions>;
81
+ leaseTtlMs?: number;
82
+ writeMinIntervalMs?: number;
83
+ /** `concurrency_ceiling_by_provider` — pins a provider at its fixture value. */
84
+ ceilingByProvider?: Record<string, number>;
85
+ /** Make the file-lease `O_EXCL` create fail (`EOPNOTSUPP`) → read-only path. */
86
+ brokenFileLease?: boolean;
87
+ }
88
+
89
+ export interface MultiprocTickCommand {
90
+ id: number;
91
+ cmd: 'tick';
92
+ /** Milliseconds to advance the injected clock before evaluating. */
93
+ advanceMs: number;
94
+ }
95
+
96
+ export interface MultiprocFailureCommand {
97
+ id: number;
98
+ cmd: 'failure';
99
+ provider: string;
100
+ /** Raw error/response text fed to the ① classifier (`429 …` = rate-limit). */
101
+ text: string;
102
+ }
103
+
104
+ export interface MultiprocPerfCommand {
105
+ id: number;
106
+ cmd: 'perf';
107
+ provider: string;
108
+ /** Δ samples for this step (default 10). */
109
+ samples?: number;
110
+ /** Δ output tokens (default 1_000). */
111
+ outputTokens?: number;
112
+ /** Δ generation time (default 1_000ms → 100ms per sample, a stable baseline). */
113
+ genMs?: number;
114
+ }
115
+
116
+ export type MultiprocCommand =
117
+ | MultiprocInitCommand
118
+ | MultiprocTickCommand
119
+ | MultiprocFailureCommand
120
+ | MultiprocPerfCommand;
121
+
122
+ /** One managed provider's state as reported after a tick. */
123
+ export interface MultiprocProviderView {
124
+ limit: number;
125
+ initial: number;
126
+ floor: number;
127
+ ceiling: number;
128
+ cleanWindows: number;
129
+ badStreak: number;
130
+ cooldownUntil: number;
131
+ badKind: BadEventKind | null;
132
+ increased: boolean;
133
+ decreased: boolean;
134
+ queueSuppressed: boolean;
135
+ cooldownActive: boolean;
136
+ rateLimitCount: number;
137
+ timeoutCount: number;
138
+ /** `true` only when this process was the writer *and* the limit landed on disk. */
139
+ written: boolean;
140
+ }
141
+
142
+ export interface MultiprocTickPayload {
143
+ evaluated: boolean;
144
+ /** Writer identity as published by the controller's own summary line. */
145
+ isWriter: boolean;
146
+ leaseMode: LeaseMode | null;
147
+ /** `summaryLines()` — the spec-mandated observable (writer identity + per-provider row). */
148
+ summary: string[];
149
+ providers: Record<string, MultiprocProviderView>;
150
+ /** On-disk shared values read back through the production reader. */
151
+ limits: Record<string, number | null>;
152
+ /** `settings.override` payloads observed since the previous reply. */
153
+ settingsOverrides: Array<{ path: string; value: unknown }>;
154
+ /** Warnings emitted since the previous reply. */
155
+ warnings: string[];
156
+ }
157
+
158
+ /** Successful reply; `tick` is present on tick replies, `recorded` on failure replies. */
159
+ export interface MultiprocOkReply {
160
+ id: number;
161
+ ok: true;
162
+ tick?: MultiprocTickPayload;
163
+ recorded?: boolean;
164
+ }
165
+
166
+ export type MultiprocReply = MultiprocOkReply | { id: number; ok: false; error: string };
167
+
168
+ // ── state ───────────────────────────────────────────────────────────────────
169
+
170
+ const WRITER_MARKER = '本进程为写者';
171
+
172
+ /** The file-lease `O_EXCL` create always fails → `createLease` degrades to read-only. */
173
+ const brokenAtomicFs: WriterFileSystemPort = {
174
+ ...nodeWriterFileSystem,
175
+ openExclusive(path: string): void {
176
+ const err = new Error(`EOPNOTSUPP: atomic create unsupported (${path})`) as NodeJS.ErrnoException;
177
+ err.code = 'EOPNOTSUPP';
178
+ throw err;
179
+ },
180
+ };
181
+
182
+ const describe = (err: unknown): string => (err instanceof Error ? err.message : String(err));
183
+
184
+ let controller: ConcurrencyController | null = null;
185
+ let redis: RedisClient | null = null;
186
+ let clock = 0;
187
+ let settingsOverrides: Array<{ path: string; value: unknown }> = [];
188
+ let warnings: string[] = [];
189
+
190
+ const warn = (text: string): void => {
191
+ warnings.push(text);
192
+ };
193
+
194
+ /**
195
+ * Synthetic cumulative `model_perf` counters (empty until a `perf` command
196
+ * grows them). An idle worker therefore carries no observation sample — the
197
+ * exact state the R1 freeze is defined on.
198
+ */
199
+ let perfRows: ModelPerfRow[] = [];
200
+
201
+ /**
202
+ * Grow one provider's cumulative counters by one step. Cumulative on purpose:
203
+ * the controller diffs consecutive reads, so every `perf` command turns the
204
+ * next evaluation window into a window with a generation sample.
205
+ */
206
+ function growPerf(command: MultiprocPerfCommand): void {
207
+ const modelKey = `${command.provider}/model`;
208
+ const previous = perfRows.find((row) => row.modelKey === modelKey);
209
+ const grown: ModelPerfRow = {
210
+ modelKey,
211
+ samples: (previous?.samples ?? 0) + (command.samples ?? 10),
212
+ outputTokens: (previous?.outputTokens ?? 0) + (command.outputTokens ?? 1_000),
213
+ genMs: (previous?.genMs ?? 0) + (command.genMs ?? 1_000),
214
+ };
215
+ perfRows = [...perfRows.filter((row) => row.modelKey !== modelKey), grown];
216
+ }
217
+
218
+ const now = (): number => clock;
219
+
220
+ function reply(payload: MultiprocReply): void {
221
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
222
+ }
223
+
224
+ function required(): ConcurrencyController {
225
+ if (!controller) throw new Error('worker not initialized: send `init` first');
226
+ return controller;
227
+ }
228
+
229
+ // ── commands ────────────────────────────────────────────────────────────────
230
+
231
+ function init(command: MultiprocInitCommand): void {
232
+ if (controller) throw new Error('worker already initialized');
233
+ clock = command.startAt;
234
+ if (command.redisPort !== null) {
235
+ redis = new RedisClient({
236
+ host: '127.0.0.1',
237
+ port: command.redisPort,
238
+ connectTimeoutMs: 500,
239
+ commandTimeoutMs: 5_000,
240
+ });
241
+ }
242
+ const settings: SettingsPort = {
243
+ override: (path, value) => {
244
+ settingsOverrides.push({ path, value });
245
+ },
246
+ };
247
+ const leaseFactory: ConcurrencyLeaseFactory | undefined = command.brokenFileLease
248
+ ? (args: ConcurrencyLeaseArgs) =>
249
+ createLease({
250
+ redis: args.redis,
251
+ lockPath: args.lockPath,
252
+ owner: args.owner,
253
+ ttlMs: args.ttlMs,
254
+ now: args.now,
255
+ fileSystem: brokenAtomicFs,
256
+ warn,
257
+ })
258
+ : undefined;
259
+ controller = createConcurrencyController({
260
+ configPath: command.configPath,
261
+ managedProviders: command.providers,
262
+ tuner: { ...TUNER_DEFAULTS, ...(command.tuner ?? {}) },
263
+ now,
264
+ fs: createNodeFsPort(),
265
+ owner: command.owner,
266
+ logDir: command.logDir,
267
+ modelPerf: { readModelPerf: () => perfRows },
268
+ redis,
269
+ settings,
270
+ warn,
271
+ ...(command.ceilingByProvider === undefined ? {} : { ceilingByProvider: command.ceilingByProvider }),
272
+ ...(command.leaseTtlMs === undefined ? {} : { leaseTtlMs: command.leaseTtlMs }),
273
+ ...(command.writeMinIntervalMs === undefined ? {} : { writeMinIntervalMs: command.writeMinIntervalMs }),
274
+ ...(leaseFactory === undefined ? {} : { leaseFactory }),
275
+ });
276
+ }
277
+
278
+ async function tick(command: MultiprocTickCommand): Promise<MultiprocTickPayload> {
279
+ const loop = required();
280
+ clock += command.advanceMs;
281
+ const result = await loop.onTick();
282
+
283
+ const providers: Record<string, MultiprocProviderView> = {};
284
+ for (const [provider, view] of Object.entries(result.providers)) {
285
+ const state = loop.stateFor(provider);
286
+ providers[provider] = {
287
+ limit: view.limit,
288
+ initial: state?.initial ?? view.limit,
289
+ floor: state?.floor ?? 1,
290
+ ceiling: state?.ceiling ?? view.limit,
291
+ cleanWindows: state?.cleanWindows ?? 0,
292
+ badStreak: state?.badStreak ?? 0,
293
+ cooldownUntil: state?.cooldownUntil ?? 0,
294
+ badKind: view.badKind,
295
+ increased: view.increased,
296
+ decreased: view.decreased,
297
+ queueSuppressed: view.queueSuppressed,
298
+ cooldownActive: view.cooldownActive,
299
+ rateLimitCount: view.rateLimitCount,
300
+ timeoutCount: view.timeoutCount,
301
+ written: view.written,
302
+ };
303
+ }
304
+
305
+ const limits: Record<string, number | null> = {};
306
+ for (const row of readManagedLimits(loop.configPath, loop.providers)) limits[row.provider] = row.value;
307
+
308
+ const summary = loop.summaryLines();
309
+ const payload: MultiprocTickPayload = {
310
+ evaluated: result.evaluated,
311
+ isWriter: summary.some((line) => line.includes(WRITER_MARKER)),
312
+ leaseMode: loop.leaseMode(),
313
+ summary,
314
+ providers,
315
+ limits,
316
+ settingsOverrides,
317
+ warnings,
318
+ };
319
+ settingsOverrides = [];
320
+ warnings = [];
321
+ return payload;
322
+ }
323
+
324
+ async function handle(command: MultiprocCommand): Promise<MultiprocReply> {
325
+ switch (command.cmd) {
326
+ case 'init':
327
+ init(command);
328
+ return { id: command.id, ok: true };
329
+ case 'tick':
330
+ return { id: command.id, ok: true, tick: await tick(command) };
331
+ case 'failure': {
332
+ const recorded = required().noteExplicitFailure(command.provider, command.text);
333
+ return { id: command.id, ok: true, recorded };
334
+ }
335
+ case 'perf':
336
+ required();
337
+ growPerf(command);
338
+ return { id: command.id, ok: true };
339
+ }
340
+ }
341
+
342
+ // ── stdin loop ──────────────────────────────────────────────────────────────
343
+
344
+ let buffered = '';
345
+ let chain: Promise<void> = Promise.resolve();
346
+
347
+ const dispatch = (line: string): void => {
348
+ chain = chain.then(async () => {
349
+ let command: MultiprocCommand;
350
+ try {
351
+ command = JSON.parse(line) as MultiprocCommand;
352
+ } catch (err) {
353
+ reply({ id: -1, ok: false, error: `bad command JSON: ${describe(err)}` });
354
+ return;
355
+ }
356
+ try {
357
+ reply(await handle(command));
358
+ } catch (err) {
359
+ reply({ id: command.id, ok: false, error: describe(err) });
360
+ }
361
+ });
362
+ };
363
+
364
+ process.stdin.setEncoding('utf8');
365
+ process.stdin.on('data', (chunk: string) => {
366
+ buffered += chunk;
367
+ let index: number;
368
+ while ((index = buffered.indexOf('\n')) >= 0) {
369
+ const line = buffered.slice(0, index);
370
+ buffered = buffered.slice(index + 1);
371
+ if (line.trim().length > 0) dispatch(line);
372
+ }
373
+ });
374
+ process.stdin.on('end', () => {
375
+ void chain.then(() => process.exit(0));
376
+ });