queasy 0.2.0 → 0.3.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 (98) hide show
  1. package/.github/workflows/check.yml +3 -0
  2. package/.github/workflows/publish.yml +3 -0
  3. package/CLAUDE.md +5 -4
  4. package/Readme.md +9 -4
  5. package/biome.json +5 -1
  6. package/dist/client.d.ts +33 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +199 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/constants.d.ts +10 -0
  11. package/dist/constants.d.ts.map +1 -0
  12. package/{src → dist}/constants.js +2 -10
  13. package/dist/constants.js.map +1 -0
  14. package/dist/errors.d.ts +7 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/{src → dist}/errors.js +1 -13
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/manager.d.ts +19 -0
  23. package/dist/manager.d.ts.map +1 -0
  24. package/dist/manager.js +67 -0
  25. package/dist/manager.js.map +1 -0
  26. package/dist/pool.d.ts +29 -0
  27. package/dist/pool.d.ts.map +1 -0
  28. package/{src → dist}/pool.js +23 -82
  29. package/dist/pool.js.map +1 -0
  30. package/dist/queasy.lua +390 -0
  31. package/dist/queue.d.ts +22 -0
  32. package/dist/queue.d.ts.map +1 -0
  33. package/dist/queue.js +81 -0
  34. package/dist/queue.js.map +1 -0
  35. package/dist/types.d.ts +92 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/utils.d.ts +4 -0
  40. package/dist/utils.d.ts.map +1 -0
  41. package/dist/utils.js +24 -0
  42. package/dist/utils.js.map +1 -0
  43. package/dist/worker.d.ts +2 -0
  44. package/dist/worker.d.ts.map +1 -0
  45. package/dist/worker.js +42 -0
  46. package/dist/worker.js.map +1 -0
  47. package/docker-compose.yml +0 -2
  48. package/fuzztest/Readme.md +185 -0
  49. package/fuzztest/fuzz.ts +356 -0
  50. package/fuzztest/handlers/cascade-a.ts +90 -0
  51. package/fuzztest/handlers/cascade-b.ts +71 -0
  52. package/fuzztest/handlers/fail-handler.ts +47 -0
  53. package/fuzztest/handlers/periodic.ts +89 -0
  54. package/fuzztest/process.ts +100 -0
  55. package/fuzztest/shared/chaos.ts +29 -0
  56. package/fuzztest/shared/stream.ts +40 -0
  57. package/package.json +8 -7
  58. package/plans/redis-options.md +279 -0
  59. package/src/client.ts +246 -0
  60. package/src/constants.ts +33 -0
  61. package/src/errors.ts +13 -0
  62. package/src/index.ts +2 -0
  63. package/src/manager.ts +78 -0
  64. package/src/pool.ts +129 -0
  65. package/src/queasy.lua +2 -3
  66. package/src/queue.ts +108 -0
  67. package/src/types.ts +16 -0
  68. package/src/{utils.js → utils.ts} +3 -20
  69. package/src/{worker.js → worker.ts} +5 -12
  70. package/test/{client.test.js → client.test.ts} +6 -7
  71. package/test/{errors.test.js → errors.test.ts} +1 -1
  72. package/test/fixtures/always-fail-handler.ts +5 -0
  73. package/test/fixtures/data-logger-handler.ts +11 -0
  74. package/test/fixtures/failure-handler.ts +6 -0
  75. package/test/fixtures/permanent-error-handler.ts +6 -0
  76. package/test/fixtures/slow-handler.ts +6 -0
  77. package/test/fixtures/success-handler.js +0 -5
  78. package/test/fixtures/success-handler.ts +6 -0
  79. package/test/fixtures/with-failure-handler.ts +5 -0
  80. package/test/{guards.test.js → guards.test.ts} +21 -34
  81. package/test/{manager.test.js → manager.test.ts} +26 -34
  82. package/test/{pool.test.js → pool.test.ts} +14 -16
  83. package/test/{queue.test.js → queue.test.ts} +21 -21
  84. package/test/{redis-functions.test.js → redis-functions.test.ts} +14 -20
  85. package/test/{utils.test.js → utils.test.ts} +1 -1
  86. package/tsconfig.json +20 -0
  87. package/jsconfig.json +0 -17
  88. package/src/client.js +0 -258
  89. package/src/index.js +0 -2
  90. package/src/manager.js +0 -94
  91. package/src/queue.js +0 -154
  92. package/test/fixtures/always-fail-handler.js +0 -8
  93. package/test/fixtures/data-logger-handler.js +0 -19
  94. package/test/fixtures/failure-handler.js +0 -9
  95. package/test/fixtures/permanent-error-handler.js +0 -10
  96. package/test/fixtures/slow-handler.js +0 -9
  97. package/test/fixtures/with-failure-handler.js +0 -8
  98. /package/test/fixtures/{no-handle-handler.js → no-handle-handler.ts} +0 -0
@@ -0,0 +1,92 @@
1
+ import type { RedisClientOptions, RedisClusterOptions } from 'redis';
2
+ type SingleNodeOptions = Pick<RedisClientOptions, 'url' | 'socket' | 'username' | 'password' | 'database'>;
3
+ export type RedisOptions = SingleNodeOptions | {
4
+ rootNodes: SingleNodeOptions[];
5
+ defaults?: Partial<SingleNodeOptions>;
6
+ nodeAddressMap?: RedisClusterOptions['nodeAddressMap'];
7
+ };
8
+ /**
9
+ * Core job identification and data
10
+ */
11
+ export interface JobCoreOptions {
12
+ /** Job ID (auto-generated if not provided) */
13
+ id?: string;
14
+ /** Job data (any JSON-serializable value) */
15
+ data?: any;
16
+ /** Wall clock timestamp (ms) before which job must not run */
17
+ runAt?: number;
18
+ }
19
+ /**
20
+ * Update behavior flags
21
+ */
22
+ export interface JobUpdateOptions {
23
+ /** Whether to replace data of waiting job with same ID */
24
+ updateData?: boolean;
25
+ /** How to update runAt */
26
+ updateRunAt?: boolean | 'if_later' | 'if_earlier';
27
+ /** Whether to reset retry_count and stall_count to 0 */
28
+ resetCounts?: boolean;
29
+ }
30
+ /**
31
+ * Complete options accepted by dispatch()
32
+ */
33
+ export type JobOptions = JobCoreOptions & JobUpdateOptions;
34
+ /**
35
+ * Job runtime state
36
+ */
37
+ export interface JobState {
38
+ /** Number of times this job has been retried */
39
+ retryCount: number;
40
+ /** Number of times this job has stalled */
41
+ stallCount: number;
42
+ }
43
+ /**
44
+ * Complete job representation passed to handlers
45
+ */
46
+ export type Job = Required<JobCoreOptions> & JobState;
47
+ /**
48
+ * Handler options
49
+ */
50
+ export interface HandlerOptions {
51
+ /** Maximum number of retries before permanent failure */
52
+ maxRetries?: number;
53
+ /** Maximum number of stalls before permanent failure */
54
+ maxStalls?: number;
55
+ /** Minimum backoff in milliseconds */
56
+ minBackoff?: number;
57
+ /** Maximum backoff in milliseconds */
58
+ maxBackoff?: number;
59
+ /** Size of the job (as a percent of total worker capacity) */
60
+ size?: number;
61
+ /** Maximum processing duration before considering stalled */
62
+ timeout?: number;
63
+ /** Priority of this queue (vs other queues) */
64
+ priority?: number;
65
+ }
66
+ /**
67
+ * Options for listen() - queue-level retry strategy
68
+ */
69
+ export interface ListenOptions extends HandlerOptions {
70
+ /** Path to failure handler module (optional) */
71
+ failHandler?: string;
72
+ /** Retry options of the fail job */
73
+ failRetryOptions?: HandlerOptions;
74
+ }
75
+ export type ExecMessage = {
76
+ op: 'exec';
77
+ queue: string;
78
+ handlerPath: string;
79
+ job: Job;
80
+ };
81
+ export type DoneMessage = {
82
+ op: 'done';
83
+ jobId: string;
84
+ error?: {
85
+ name: string;
86
+ message: string;
87
+ retryAt?: number;
88
+ kind?: 'retriable' | 'permanent' | 'stall';
89
+ };
90
+ };
91
+ export {};
92
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAErE,KAAK,iBAAiB,GAAG,IAAI,CACzB,kBAAkB,EAClB,KAAK,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,CAC1D,CAAC;AAEF,MAAM,MAAM,YAAY,GAClB,iBAAiB,GACjB;IACI,SAAS,EAAE,iBAAiB,EAAE,CAAC;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACtC,cAAc,CAAC,EAAE,mBAAmB,CAAC,gBAAgB,CAAC,CAAC;CAC1D,CAAC;AAER;;GAEG;AACH,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,6CAA6C;IAE7C,IAAI,CAAC,EAAE,GAAG,CAAC;IACX,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC7B,0DAA0D;IAC1D,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0BAA0B;IAC1B,WAAW,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,YAAY,CAAC;IAClD,wDAAwD;IACxD,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,cAAc,GAAG,gBAAgB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,QAAQ;IACrB,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,cAAc,CAAC,GAAG,QAAQ,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC3B,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,aAAc,SAAQ,cAAc;IACjD,gDAAgD;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,oCAAoC;IACpC,gBAAgB,CAAC,EAAE,cAAc,CAAC;CACrC;AAED,MAAM,MAAM,WAAW,GAAG;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,GAAG,CAAC;CACZ,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE;QACJ,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,IAAI,CAAC,EAAE,WAAW,GAAG,WAAW,GAAG,OAAO,CAAC;KAC9C,CAAC;CACL,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,4 @@
1
+ export declare function generateId(length?: number): string;
2
+ export declare function parseVersion(version: string | null | undefined): number[];
3
+ export declare function compareSemver(a: number[], b: number[]): -1 | 0 | 1;
4
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,UAAU,CAAC,MAAM,SAAK,GAAG,MAAM,CAO9C;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CAIzE;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAMlE"}
package/dist/utils.js ADDED
@@ -0,0 +1,24 @@
1
+ export function generateId(length = 20) {
2
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
3
+ let id = '';
4
+ for (let i = 0; i < length; i++) {
5
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
6
+ }
7
+ return id;
8
+ }
9
+ export function parseVersion(version) {
10
+ const parsed = String(version).split('.').map(Number);
11
+ if (parsed.some((n) => Number.isNaN(n)))
12
+ return [0];
13
+ return parsed;
14
+ }
15
+ export function compareSemver(a, b) {
16
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
17
+ if (a[i] !== b[i])
18
+ return a[i] < b[i] ? -1 : 1;
19
+ }
20
+ if (a.length !== b.length)
21
+ return a.length < b.length ? -1 : 1;
22
+ return 0;
23
+ }
24
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,UAAU,CAAC,MAAM,GAAG,EAAE;IAClC,MAAM,KAAK,GAAG,gEAAgE,CAAC;IAC/E,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC9B,EAAE,IAAI,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;IACjE,CAAC;IACD,OAAO,EAAE,CAAC;AACd,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAkC;IAC3D,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACtD,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACpD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,CAAW,EAAE,CAAW;IAClD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACpD,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,OAAO,CAAC,CAAC;AACb,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":""}
package/dist/worker.js ADDED
@@ -0,0 +1,42 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { pathToFileURL } from 'node:url';
10
+ import { parentPort, setEnvironmentData } from 'node:worker_threads';
11
+ import { PermanentError } from "./errors.js";
12
+ if (!parentPort)
13
+ throw new Error('Worker cannot be executed directly.');
14
+ setEnvironmentData('queasy_worker_context', true);
15
+ parentPort.on('message', async (msg) => {
16
+ const { handlerPath, job } = msg;
17
+ try {
18
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(handlerPath).href));
19
+ if (typeof mod.handle !== 'function') {
20
+ throw new Error(`Unable to load handler ${handlerPath}`);
21
+ }
22
+ await mod.handle(job.data, job);
23
+ send({ op: 'done', jobId: job.id });
24
+ }
25
+ catch (err) {
26
+ const { message, name, retryAt } = err;
27
+ send({
28
+ op: 'done',
29
+ jobId: job.id,
30
+ error: {
31
+ name,
32
+ message,
33
+ retryAt,
34
+ kind: err instanceof PermanentError ? 'permanent' : 'retriable',
35
+ },
36
+ });
37
+ }
38
+ });
39
+ function send(message) {
40
+ parentPort?.postMessage(message);
41
+ }
42
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":";;;;;;;;AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7C,IAAI,CAAC,UAAU;IAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;AACxE,kBAAkB,CAAC,uBAAuB,EAAE,IAAI,CAAC,CAAC;AAElD,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAgB,EAAE,EAAE;IAChD,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,GAAG,GAAG,CAAC;IACjC,IAAI,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,MAAM,kCAAC,aAAa,CAAC,WAAW,CAAC,CAAC,IAAI,EAAC,CAAC;QAC1D,IAAI,OAAO,GAAG,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,0BAA0B,WAAW,EAAE,CAAC,CAAC;QAC7D,CAAC;QAED,MAAM,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAChC,IAAI,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;IACxC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACX,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,GAAmC,CAAC;QAEvE,IAAI,CAAC;YACD,EAAE,EAAE,MAAM;YACV,KAAK,EAAE,GAAG,CAAC,EAAE;YACb,KAAK,EAAE;gBACH,IAAI;gBACJ,OAAO;gBACP,OAAO;gBACP,IAAI,EAAE,GAAG,YAAY,cAAc,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,WAAW;aAClE;SACJ,CAAC,CAAC;IACP,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,SAAS,IAAI,CAAC,OAAoB;IAC9B,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;AACrC,CAAC"}
@@ -7,8 +7,6 @@ services:
7
7
  ports:
8
8
  - '6379:6379'
9
9
  command: redis-server --save 60 1 --loglevel warning
10
- volumes:
11
- - redis-data:/data
12
10
  healthcheck:
13
11
  test: ['CMD', 'redis-cli', 'ping']
14
12
  interval: 5s
@@ -0,0 +1,185 @@
1
+ # Queasy Fuzz Test Plan
2
+
3
+ A long-running end-to-end fuzz test that simulates random failures and continuously verifies core system invariants.
4
+
5
+ ## Invariants Verified
6
+
7
+ 1. **Mutual exclusion**: Two jobs with the same Job ID are never processed by different clients or worker threads simultaneously.
8
+ 2. **No re-processing of successful jobs**: A job that has succeeded is never processed again.
9
+ 3. **Scheduling**: No job is processed before its `run_at` time.
10
+ 4. **Priority ordering within a queue**: No job starts processing while another job in the same queue with a lower `run_at` is still waiting (i.e., eligible jobs are dequeued in order).
11
+ 5. **Fail handler completeness**: If a fail handler is registered, every job that does not eventually succeed MUST result in the fail handler being invoked.
12
+ 6. **Queue progress (priority starvation prevention)**: Non-empty queues at the highest priority level always make progress. When they drain, queues at the next priority level begin making progress.
13
+
14
+ ## Structure Overview
15
+
16
+ ```
17
+ fuzztest/
18
+ Readme.md # This file
19
+ fuzz.js # Orchestrator: spawns child processes, monitors shared state
20
+ process.js # Child process: sets up clients and listens on all queues
21
+ handlers/
22
+ periodic.js # Re-queues itself; dispatches cascade jobs; occasionally stalls/crashes
23
+ cascade-a.js # Dispatched by periodic; dispatches into cascade-b
24
+ cascade-b.js # Dispatched by cascade-a; final handler
25
+ fail-handler.js # Shared fail handler for all queues; records invocations
26
+ shared/
27
+ state.js # In-process shared state helpers (for the orchestrator)
28
+ log.js # Structured logger (writes to fuzz-output.log, never throws)
29
+ ```
30
+
31
+ ## Process Architecture
32
+
33
+ The orchestrator (`fuzz.js`) spawns **N child processes** (default: 4). Each child process creates one Redis client and calls `listen()` on every queue. The orchestrator itself does not process jobs — it only monitors invariants and manages the lifecycle.
34
+
35
+ Handlers write events (job start, finish, fail, stall) directly to a Redis stream (`fuzz:events`). The orchestrator reads from this stream and maintains a shared in-memory log of events, checking invariants after each one. Child processes do not need to forward events to the orchestrator themselves — the stream is the shared channel.
36
+
37
+ Child processes are deliberately killed and restarted periodically to simulate crashes. A killed process' checked-out jobs will be swept and retried/failed by the remaining processes.
38
+
39
+ ## Queue Configuration
40
+
41
+ Three queues at different priority levels, all listened on by every child process. Parameters are kept small to produce many events quickly:
42
+
43
+ | Parameter | `{fuzz}:periodic` | `{fuzz}:cascade-a` | `{fuzz}:cascade-b` |
44
+ |---|---|---|---|
45
+ | Handler | `periodic.js` | `cascade-a.js` | `cascade-b.js` |
46
+ | Priority | 300 | 200 | 100 |
47
+ | `maxRetries` | 3 | 3 | 3 |
48
+ | `maxStalls` | 2 | 2 | 2 |
49
+ | `minBackoff` | 200 ms | 200 ms | 200 ms |
50
+ | `maxBackoff` | 2 000 ms | 2 000 ms | 2 000 ms |
51
+ | `timeout` | 3 000 ms | 3 000 ms | 3 000 ms |
52
+ | `size` | 10 | 10 | 10 |
53
+ | `failHandler` | `fail-handler.js` | `fail-handler.js` | `fail-handler.js` |
54
+ | `failRetryOptions.maxRetries` | 5 | 5 | 5 |
55
+ | `failRetryOptions.minBackoff` | 200 ms | 200 ms | 200 ms |
56
+
57
+ The short `timeout` (3 s) means stalling jobs are detected and swept quickly. The short `minBackoff` / `maxBackoff` window (200 ms – 2 s) means retries cycle fast. With `maxRetries: 3` and `maxStalls: 2`, most failed jobs reach the fail handler within seconds.
58
+
59
+ ## Periodic Jobs (Seed)
60
+
61
+ A fixed set of periodic job IDs (e.g., `periodic-0` through `periodic-4`) are dispatched by the orchestrator at startup. Each periodic handler:
62
+
63
+ 1. Records the current processing event by writing `{ type: 'start', queue, id, threadId, clientId, startedAt }` to the `fuzz:events` Redis stream.
64
+ 2. Optionally sleeps for a random short delay.
65
+ 3. Dispatches a cascade-a job with a unique ID and a `runAt` randomly up to 2 seconds in the future.
66
+ 4. Re-dispatches itself (same job ID, `updateRunAt: true`) with a delay of 1–5 seconds, so the job continues to fire periodically.
67
+ 5. On success, writes `{ type: 'finish', queue, id, threadId, clientId, finishedAt }` to the `fuzz:events` stream.
68
+
69
+ The fail handler for periodic jobs also re-dispatches the same periodic job ID (with a delay), ensuring periodic jobs survive permanent failures. This lets the orchestrator assert that periodic jobs keep running indefinitely.
70
+
71
+ ## Cascade Jobs
72
+
73
+ `cascade-a.js`:
74
+ - Records start/finish events.
75
+ - Dispatches one or two `cascade-b` jobs with unique IDs.
76
+ - Subject to all chaos behaviors (see below).
77
+
78
+ `cascade-b.js`:
79
+ - Records start/finish events.
80
+ - Terminal handler; does not dispatch further jobs.
81
+ - Subject to all chaos behaviors (see below).
82
+
83
+ ## Chaos Behaviors
84
+
85
+ All handlers are subject to all chaos behaviors. The probabilities below are per-invocation and apply uniformly across `periodic.js`, `cascade-a.js`, and `cascade-b.js`:
86
+
87
+ | Behavior | Probability | Notes |
88
+ |---|---|---|
89
+ | Normal completion | ~65% | Dispatches downstream jobs (if any), then returns |
90
+ | Retriable error (throws `Error`) | ~15% | No downstream dispatch |
91
+ | Permanent error (throws `PermanentError`) | ~5% | No downstream dispatch |
92
+ | Stall (returns a never-resolving promise) | ~10% | Detected after `timeout` (3 s); counts as a stall |
93
+ | CPU spin (blocks the worker thread) | ~3% | Tight loop until the process detects the hang and kills the thread (via `timeout`) |
94
+ | Crash (causes the child process to exit) | ~2% | Handler writes a "crash-me" flag to Redis; main thread polls and exits |
95
+
96
+ With `timeout: 3000`, stalling and spinning jobs are swept within ~3–13 seconds (timeout + heartbeat sweep interval). With `maxStalls: 2`, two stalls exhaust the stall budget and the job is sent to the fail handler, cycling fast.
97
+
98
+ When a child process crashes, the orchestrator detects the exit event and restarts a new child process after a short delay.
99
+
100
+ ## Event Logging and Invariant Checking
101
+
102
+ The orchestrator maintains an append-only in-memory event log. Each entry contains:
103
+ ```js
104
+ { type, queue, id, threadId, clientId, timestamp }
105
+ ```
106
+ where `type` is one of: `start`, `finish`, `fail`, `stall`, `cancel`.
107
+
108
+ After each event is appended, the orchestrator runs incremental invariant checks:
109
+
110
+ ### Invariant 1: Mutual Exclusion
111
+ Maintain a `Map<jobId, { clientId, threadId, startedAt }>` of currently-active jobs. On `start`, check that the job ID is not already in the map. On `finish`/`fail`/`stall`, remove it.
112
+
113
+ If a `start` event arrives for a job ID already in the map → **VIOLATION**.
114
+
115
+ ### Invariant 2: No Re-processing of Succeeded Jobs
116
+ Maintain a `Set<jobId>` of successfully finished job IDs. On `start`, check that the ID is not in this set.
117
+
118
+ If a `start` event arrives for a job ID in the succeeded set → **VIOLATION**.
119
+
120
+ Note: Re-processing after a stall or retry is expected and must not be flagged.
121
+
122
+ ### Invariant 3: Scheduling (No Early Processing)
123
+ Each `start` event includes `startedAt` (wall clock). Each job dispatch records an intended `runAt`. On `start`, verify `startedAt >= runAt - CLOCK_TOLERANCE_MS`.
124
+
125
+ If `startedAt < runAt - CLOCK_TOLERANCE_MS` → **VIOLATION**.
126
+
127
+ `CLOCK_TOLERANCE_MS` accounts for clock skew between the orchestrator, child processes, and Redis (default: 100ms).
128
+
129
+ ### Invariant 4: Priority Ordering
130
+ Track the earliest-known `runAt` for jobs dispatched into each queue but not yet started. When a `start` event arrives for a job in that queue, verify no other eligible job (with `runAt <= now`) in the same queue has a lower `runAt` that has been waiting longer.
131
+
132
+ This invariant is best-effort and checked with a configurable lag (e.g., 200ms) to account for the inherent race between dequeue polling and dispatch. A violation is only flagged when the ordering difference exceeds this lag.
133
+
134
+ ### Invariant 5: Fail Handler Completeness
135
+ Track every job that has been dispatched (by ID). When a job exceeds its `maxRetries` or receives a permanent error, a fail event should be observed. Maintain a map `{ jobId → { exhausted: bool, failSeen: bool } }`. After a configurable drain period (e.g., 30 seconds after a queue goes quiet), check that every exhausted job has a corresponding `fail` event.
136
+
137
+ ### Invariant 6: Queue Progress
138
+ The orchestrator monitors the time since the last `start` event per queue. If a queue is known to be non-empty (based on dispatched vs finished counts) and no `start` event has been seen for more than a configurable `STALL_THRESHOLD_MS` (e.g., 30 seconds), flag a progress violation.
139
+
140
+ Priority starvation is checked by verifying that the low-priority queue does not process jobs while the high-priority queue has outstanding jobs older than the dequeue poll interval.
141
+
142
+ ## Output and Reporting
143
+
144
+ Violations are logged to stdout and to `fuzz-output.log` with full context. The process does **not** exit on a violation — it logs and continues, accumulating a count of violations. A summary is printed periodically (every 60 seconds) and on `SIGINT`.
145
+
146
+ Log format (newline-delimited JSON):
147
+ ```json
148
+ { "time": "...", "level": "info|warn|error", "msg": "...", "data": { ... } }
149
+ ```
150
+
151
+ Violation entries use level `"error"` and include the invariant name, the offending event, and relevant recent history.
152
+
153
+ ## Configuration
154
+
155
+ All tunable parameters live at the top of `fuzz.js` as named constants:
156
+
157
+ ```js
158
+ const NUM_PROCESSES = 4; // Child processes
159
+ const NUM_PERIODIC_JOBS = 5; // Fixed periodic job IDs
160
+ const PERIODIC_MIN_DELAY = 1000; // ms before re-queuing self
161
+ const PERIODIC_MAX_DELAY = 5000;
162
+ const CRASH_INTERVAL_MS = 30000; // Orchestrator kills a random child process this often
163
+ const CLOCK_TOLERANCE_MS = 100;
164
+ const STALL_THRESHOLD_MS = 30000;
165
+ const PRIORITY_LAG_MS = 200;
166
+ const LOG_FILE = 'fuzz-output.log';
167
+ ```
168
+
169
+ ## Running
170
+
171
+ The fuzz test is separate from the default test suite and is never run by `npm test`. It is started manually:
172
+
173
+ ```sh
174
+ node fuzztest/fuzz.js
175
+ ```
176
+
177
+ It runs indefinitely. Stop with `Ctrl+C`. A summary of violations and events processed will be printed on exit.
178
+
179
+ ## Notes on Implementation
180
+
181
+ - Child processes use the `queasy` library's public API (`queue()`, `dispatch()`, `listen()`). They do not talk directly to Redis.
182
+ - The orchestrator does not import from `src/`; it only spawns child processes and learns about child process lifecycle only from the `spawn` and `exit` events.
183
+ - All handler modules in `fuzztest/handlers/` must be self-contained ESM modules that can be passed as `handlerPath` to `queue.listen()`.
184
+ - Handlers write events to the `fuzz:events` Redis stream using a dedicated Redis client created at handler module load time. The orchestrator reads from this stream via `XREAD BLOCK`. This is the only communication channel between handlers and the orchestrator — no IPC is used.
185
+ - The chaos crash behavior must be triggered from the child process's main thread, not from inside a handler's worker thread. To simulate a crash, the handler uses `postMessage` to send a `{ type: 'crash' }` message to the main thread, which listens for it and calls `process.exit()`.