@convex-dev/workpool 0.1.2 → 0.2.0-alpha.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.
Files changed (125) hide show
  1. package/README.md +155 -17
  2. package/dist/commonjs/client/index.d.ts +123 -35
  3. package/dist/commonjs/client/index.d.ts.map +1 -1
  4. package/dist/commonjs/client/index.js +122 -15
  5. package/dist/commonjs/client/index.js.map +1 -1
  6. package/dist/commonjs/client/utils.d.ts +16 -0
  7. package/dist/commonjs/client/utils.d.ts.map +1 -0
  8. package/dist/commonjs/client/utils.js +2 -0
  9. package/dist/commonjs/client/utils.js.map +1 -0
  10. package/dist/commonjs/component/complete.d.ts +89 -0
  11. package/dist/commonjs/component/complete.d.ts.map +1 -0
  12. package/dist/commonjs/component/complete.js +80 -0
  13. package/dist/commonjs/component/complete.js.map +1 -0
  14. package/dist/commonjs/component/convex.config.d.ts.map +1 -1
  15. package/dist/commonjs/component/convex.config.js +0 -2
  16. package/dist/commonjs/component/convex.config.js.map +1 -1
  17. package/dist/commonjs/component/kick.d.ts +9 -0
  18. package/dist/commonjs/component/kick.d.ts.map +1 -0
  19. package/dist/commonjs/component/kick.js +97 -0
  20. package/dist/commonjs/component/kick.js.map +1 -0
  21. package/dist/commonjs/component/lib.d.ts +23 -32
  22. package/dist/commonjs/component/lib.d.ts.map +1 -1
  23. package/dist/commonjs/component/lib.js +91 -563
  24. package/dist/commonjs/component/lib.js.map +1 -1
  25. package/dist/commonjs/component/logging.d.ts +5 -3
  26. package/dist/commonjs/component/logging.d.ts.map +1 -1
  27. package/dist/commonjs/component/logging.js +13 -2
  28. package/dist/commonjs/component/logging.js.map +1 -1
  29. package/dist/commonjs/component/loop.d.ts +13 -0
  30. package/dist/commonjs/component/loop.d.ts.map +1 -0
  31. package/dist/commonjs/component/loop.js +482 -0
  32. package/dist/commonjs/component/loop.js.map +1 -0
  33. package/dist/commonjs/component/recovery.d.ts +24 -0
  34. package/dist/commonjs/component/recovery.d.ts.map +1 -0
  35. package/dist/commonjs/component/recovery.js +94 -0
  36. package/dist/commonjs/component/recovery.js.map +1 -0
  37. package/dist/commonjs/component/schema.d.ts +167 -93
  38. package/dist/commonjs/component/schema.d.ts.map +1 -1
  39. package/dist/commonjs/component/schema.js +56 -65
  40. package/dist/commonjs/component/schema.js.map +1 -1
  41. package/dist/commonjs/component/shared.d.ts +138 -0
  42. package/dist/commonjs/component/shared.d.ts.map +1 -0
  43. package/dist/commonjs/component/shared.js +77 -0
  44. package/dist/commonjs/component/shared.js.map +1 -0
  45. package/dist/commonjs/component/stats.d.ts +6 -3
  46. package/dist/commonjs/component/stats.d.ts.map +1 -1
  47. package/dist/commonjs/component/stats.js +23 -4
  48. package/dist/commonjs/component/stats.js.map +1 -1
  49. package/dist/commonjs/component/worker.d.ts +15 -0
  50. package/dist/commonjs/component/worker.d.ts.map +1 -0
  51. package/dist/commonjs/component/worker.js +73 -0
  52. package/dist/commonjs/component/worker.js.map +1 -0
  53. package/dist/esm/client/index.d.ts +123 -35
  54. package/dist/esm/client/index.d.ts.map +1 -1
  55. package/dist/esm/client/index.js +122 -15
  56. package/dist/esm/client/index.js.map +1 -1
  57. package/dist/esm/client/utils.d.ts +16 -0
  58. package/dist/esm/client/utils.d.ts.map +1 -0
  59. package/dist/esm/client/utils.js +2 -0
  60. package/dist/esm/client/utils.js.map +1 -0
  61. package/dist/esm/component/complete.d.ts +89 -0
  62. package/dist/esm/component/complete.d.ts.map +1 -0
  63. package/dist/esm/component/complete.js +80 -0
  64. package/dist/esm/component/complete.js.map +1 -0
  65. package/dist/esm/component/convex.config.d.ts.map +1 -1
  66. package/dist/esm/component/convex.config.js +0 -2
  67. package/dist/esm/component/convex.config.js.map +1 -1
  68. package/dist/esm/component/kick.d.ts +9 -0
  69. package/dist/esm/component/kick.d.ts.map +1 -0
  70. package/dist/esm/component/kick.js +97 -0
  71. package/dist/esm/component/kick.js.map +1 -0
  72. package/dist/esm/component/lib.d.ts +23 -32
  73. package/dist/esm/component/lib.d.ts.map +1 -1
  74. package/dist/esm/component/lib.js +91 -563
  75. package/dist/esm/component/lib.js.map +1 -1
  76. package/dist/esm/component/logging.d.ts +5 -3
  77. package/dist/esm/component/logging.d.ts.map +1 -1
  78. package/dist/esm/component/logging.js +13 -2
  79. package/dist/esm/component/logging.js.map +1 -1
  80. package/dist/esm/component/loop.d.ts +13 -0
  81. package/dist/esm/component/loop.d.ts.map +1 -0
  82. package/dist/esm/component/loop.js +482 -0
  83. package/dist/esm/component/loop.js.map +1 -0
  84. package/dist/esm/component/recovery.d.ts +24 -0
  85. package/dist/esm/component/recovery.d.ts.map +1 -0
  86. package/dist/esm/component/recovery.js +94 -0
  87. package/dist/esm/component/recovery.js.map +1 -0
  88. package/dist/esm/component/schema.d.ts +167 -93
  89. package/dist/esm/component/schema.d.ts.map +1 -1
  90. package/dist/esm/component/schema.js +56 -65
  91. package/dist/esm/component/schema.js.map +1 -1
  92. package/dist/esm/component/shared.d.ts +138 -0
  93. package/dist/esm/component/shared.d.ts.map +1 -0
  94. package/dist/esm/component/shared.js +77 -0
  95. package/dist/esm/component/shared.js.map +1 -0
  96. package/dist/esm/component/stats.d.ts +6 -3
  97. package/dist/esm/component/stats.d.ts.map +1 -1
  98. package/dist/esm/component/stats.js +23 -4
  99. package/dist/esm/component/stats.js.map +1 -1
  100. package/dist/esm/component/worker.d.ts +15 -0
  101. package/dist/esm/component/worker.d.ts.map +1 -0
  102. package/dist/esm/component/worker.js +73 -0
  103. package/dist/esm/component/worker.js.map +1 -0
  104. package/package.json +6 -5
  105. package/src/client/index.ts +232 -68
  106. package/src/client/utils.ts +45 -0
  107. package/src/component/README.md +73 -0
  108. package/src/component/_generated/api.d.ts +38 -66
  109. package/src/component/complete.test.ts +508 -0
  110. package/src/component/complete.ts +98 -0
  111. package/src/component/convex.config.ts +0 -3
  112. package/src/component/kick.test.ts +285 -0
  113. package/src/component/kick.ts +118 -0
  114. package/src/component/lib.test.ts +448 -0
  115. package/src/component/lib.ts +105 -667
  116. package/src/component/logging.ts +24 -12
  117. package/src/component/loop.test.ts +1204 -0
  118. package/src/component/loop.ts +637 -0
  119. package/src/component/recovery.test.ts +541 -0
  120. package/src/component/recovery.ts +96 -0
  121. package/src/component/schema.ts +61 -77
  122. package/src/component/setup.test.ts +5 -0
  123. package/src/component/shared.ts +141 -0
  124. package/src/component/stats.ts +26 -8
  125. package/src/component/worker.ts +81 -0
@@ -0,0 +1,285 @@
1
+ import { convexTest } from "convex-test";
2
+ import {
3
+ afterEach,
4
+ assert,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ test,
9
+ vi,
10
+ } from "vitest";
11
+ import schema from "./schema.js";
12
+ import { modules } from "./setup.test.js";
13
+ import { DEFAULT_MAX_PARALLELISM, kickMainLoop } from "./kick.js";
14
+ import { DEFAULT_LOG_LEVEL } from "./logging.js";
15
+ import { internal } from "./_generated/api";
16
+ import { toSegment, fromSegment, nextSegment } from "./shared";
17
+ import { Id } from "./_generated/dataModel.js";
18
+
19
+ describe("kickMainLoop", () => {
20
+ beforeEach(() => {
21
+ vi.useFakeTimers();
22
+ vi.setSystemTime(new Date(1765432101234)); // Set to a known time
23
+ });
24
+ afterEach(() => {
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ test("ensures it creates globals on first call", async () => {
29
+ const t = convexTest(schema, modules);
30
+ await t.run(async (ctx) => {
31
+ await kickMainLoop(ctx, "enqueue");
32
+ const globals = await ctx.db.query("globals").unique();
33
+ expect(globals).not.toBeNull();
34
+ const runStatus = await ctx.db.query("runStatus").unique();
35
+ expect(runStatus).not.toBeNull();
36
+ assert(runStatus);
37
+ expect(runStatus.state.kind).toBe("running");
38
+ const internalState = await ctx.db.query("internalState").unique();
39
+ expect(internalState).not.toBeNull();
40
+ assert(internalState);
41
+ expect(internalState.generation).toBe(0n);
42
+ });
43
+ });
44
+
45
+ test("it updates the globals when they change", async () => {
46
+ const t = convexTest(schema, modules);
47
+ await t.run(async (ctx) => {
48
+ await kickMainLoop(ctx, "enqueue");
49
+ const globals = await ctx.db.query("globals").unique();
50
+ expect(globals).not.toBeNull();
51
+ assert(globals);
52
+ expect(globals.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM);
53
+ expect(globals.logLevel).toBe(DEFAULT_LOG_LEVEL);
54
+ await kickMainLoop(ctx, "enqueue", {
55
+ maxParallelism: DEFAULT_MAX_PARALLELISM + 1,
56
+ logLevel: "DEBUG",
57
+ });
58
+ const after = await ctx.db.query("globals").unique();
59
+ expect(after).not.toBeNull();
60
+ assert(after);
61
+ expect(after.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM + 1);
62
+ expect(after.logLevel).toBe("DEBUG");
63
+ });
64
+ });
65
+
66
+ test("does not kick when already running", async () => {
67
+ const t = convexTest(schema, modules);
68
+ await t.run(async (ctx) => {
69
+ // First kick to set up initial state
70
+ await kickMainLoop(ctx, "enqueue");
71
+ const runStatus = await ctx.db.query("runStatus").unique();
72
+ assert(runStatus);
73
+ expect(runStatus.state.kind).toBe("running");
74
+
75
+ // Second kick should not change state
76
+ await kickMainLoop(ctx, "enqueue");
77
+ const afterStatus = await ctx.db.query("runStatus").unique();
78
+ assert(afterStatus);
79
+ expect(afterStatus.state.kind).toBe("running");
80
+ expect(afterStatus._id).toBe(runStatus._id);
81
+ });
82
+ });
83
+
84
+ test("kicks when scheduled with later segment", async () => {
85
+ const t = convexTest(schema, modules);
86
+ await t.run(async (ctx) => {
87
+ // Set up initial scheduled state
88
+ await kickMainLoop(ctx, "enqueue");
89
+ const runStatus = await ctx.db.query("runStatus").unique();
90
+ assert(runStatus);
91
+
92
+ // Get current segment and schedule for future
93
+ const now = Date.now();
94
+ const futureTime = now + 10000; // 10 seconds in future
95
+ const futureSegment = toSegment(futureTime);
96
+
97
+ // Manually set to scheduled state with future segment
98
+ const scheduledId = await ctx.scheduler.runAfter(
99
+ fromSegment(futureSegment) - now,
100
+ internal.loop.main,
101
+ {
102
+ generation: 0n,
103
+ segment: futureSegment,
104
+ }
105
+ );
106
+ await ctx.db.patch(runStatus._id, {
107
+ state: {
108
+ kind: "scheduled",
109
+ scheduledId,
110
+ saturated: false,
111
+ generation: 0n,
112
+ segment: futureSegment,
113
+ },
114
+ });
115
+
116
+ // Kick should reschedule to run sooner
117
+ await kickMainLoop(ctx, "enqueue");
118
+
119
+ const afterStatus = await ctx.db.query("runStatus").unique();
120
+ assert(afterStatus);
121
+ expect(afterStatus.state.kind).toBe("running");
122
+ });
123
+ });
124
+
125
+ test("does not kick when scheduled and saturated", async () => {
126
+ const t = convexTest(schema, modules);
127
+ await t.run(async (ctx) => {
128
+ // Set up initial scheduled state
129
+ await kickMainLoop(ctx, "enqueue");
130
+ const runStatus = await ctx.db.query("runStatus").unique();
131
+ assert(runStatus);
132
+
133
+ // Get current segment
134
+ const now = Date.now();
135
+ const nearFutureTime = now + 1000; // 1 second in future
136
+ const nearFutureSegment = toSegment(nearFutureTime);
137
+
138
+ // Manually set to scheduled saturated state
139
+ const scheduledId = await ctx.scheduler.runAfter(
140
+ fromSegment(nearFutureSegment) - now,
141
+ internal.loop.main,
142
+ {
143
+ generation: 0n,
144
+ segment: nearFutureSegment,
145
+ }
146
+ );
147
+ await ctx.db.patch(runStatus._id, {
148
+ state: {
149
+ kind: "scheduled",
150
+ scheduledId,
151
+ saturated: true,
152
+ generation: 0n,
153
+ segment: nearFutureSegment,
154
+ },
155
+ });
156
+
157
+ // Kick should not change state when saturated
158
+ await kickMainLoop(ctx, "enqueue");
159
+ const afterStatus = await ctx.db.query("runStatus").unique();
160
+ assert(afterStatus);
161
+ expect(afterStatus.state.kind).toBe("scheduled");
162
+ assert(afterStatus.state.kind === "scheduled");
163
+ expect(afterStatus.state.saturated).toBe(true);
164
+ });
165
+ });
166
+
167
+ test("recovers if runStatus is deleted but other state exists", async () => {
168
+ const t = convexTest(schema, modules);
169
+ await t.run(async (ctx) => {
170
+ // First create all state
171
+ await kickMainLoop(ctx, "enqueue");
172
+
173
+ // Delete runStatus
174
+ const runStatus = await ctx.db.query("runStatus").unique();
175
+ assert(runStatus);
176
+ await ctx.db.delete(runStatus._id);
177
+
178
+ // Kick should recreate runStatus
179
+ await kickMainLoop(ctx, "complete");
180
+ const newRunStatus = await ctx.db.query("runStatus").unique();
181
+ expect(newRunStatus).not.toBeNull();
182
+ assert(newRunStatus);
183
+ expect(newRunStatus.state.kind).toBe("running");
184
+ });
185
+ });
186
+
187
+ test("recovers if globals is deleted but other state exists", async () => {
188
+ const t = convexTest(schema, modules);
189
+ await t.run(async (ctx) => {
190
+ // First create all state
191
+ await kickMainLoop(ctx, "enqueue");
192
+
193
+ // Delete globals
194
+ const globals = await ctx.db.query("globals").unique();
195
+ assert(globals);
196
+ await ctx.db.delete(globals._id);
197
+
198
+ // Kick should recreate globals
199
+ await kickMainLoop(ctx, "complete");
200
+ const newGlobals = await ctx.db.query("globals").unique();
201
+ expect(newGlobals).not.toBeNull();
202
+ assert(newGlobals);
203
+ expect(newGlobals.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM);
204
+ expect(newGlobals.logLevel).toBe(DEFAULT_LOG_LEVEL);
205
+ });
206
+ });
207
+
208
+ test("handles race conditions between multiple kicks", async () => {
209
+ const t = convexTest(schema, modules);
210
+ // Run kicks in separate transactions to simulate concurrent access
211
+ await Promise.all(
212
+ Array.from({ length: 10 }, () =>
213
+ t.run(async (ctx) => {
214
+ await kickMainLoop(ctx, "enqueue");
215
+ })
216
+ )
217
+ );
218
+
219
+ // Check final state in a new transaction
220
+ await t.run(async (ctx) => {
221
+ // Should end up with single consistent state
222
+ const runStatus = await ctx.db.query("runStatus").unique();
223
+ const internalState = await ctx.db.query("internalState").unique();
224
+ const globals = await ctx.db.query("globals").unique();
225
+
226
+ expect(runStatus).not.toBeNull();
227
+ expect(internalState).not.toBeNull();
228
+ expect(globals).not.toBeNull();
229
+ assert(runStatus);
230
+ assert(internalState);
231
+ assert(globals);
232
+
233
+ expect(runStatus.state.kind).toBe("running");
234
+ expect(internalState.generation).toBe(0n);
235
+ expect(globals.maxParallelism).toBe(DEFAULT_MAX_PARALLELISM);
236
+ });
237
+ });
238
+
239
+ test("preserves state between kicks with different sources", async () => {
240
+ const t = convexTest(schema, modules);
241
+ await t.run(async (ctx) => {
242
+ // Initial kick with custom config
243
+ await kickMainLoop(ctx, "enqueue", {
244
+ maxParallelism: 5,
245
+ logLevel: "DEBUG",
246
+ });
247
+
248
+ // Kick from different sources
249
+ await kickMainLoop(ctx, "cancel");
250
+ await kickMainLoop(ctx, "complete");
251
+
252
+ // Config should be preserved
253
+ const globals = await ctx.db.query("globals").unique();
254
+ expect(globals).not.toBeNull();
255
+ assert(globals);
256
+ expect(globals.maxParallelism).toBe(5);
257
+ expect(globals.logLevel).toBe("DEBUG");
258
+ });
259
+ });
260
+
261
+ test("cancels and starts running when scheduled", async () => {
262
+ const t = convexTest(schema, modules);
263
+ await t.run(async (ctx) => {
264
+ await kickMainLoop(ctx, "enqueue");
265
+ const runStatus = await ctx.db.query("runStatus").unique();
266
+ assert(runStatus);
267
+ const segment = nextSegment() + 10n;
268
+ await ctx.db.patch(runStatus._id, {
269
+ state: {
270
+ generation: 0n,
271
+ saturated: false,
272
+ kind: "scheduled",
273
+ segment,
274
+ scheduledId: "" as Id<"_scheduled_functions">,
275
+ },
276
+ });
277
+ // await all scheduled functions to run
278
+ await kickMainLoop(ctx, "enqueue");
279
+ const afterStatus = await ctx.db.query("runStatus").unique();
280
+ assert(afterStatus);
281
+ expect(afterStatus.state.kind).toBe("running");
282
+ assert(afterStatus.state.kind === "running");
283
+ });
284
+ });
285
+ });
@@ -0,0 +1,118 @@
1
+ import { internal } from "./_generated/api.js";
2
+ import { internalMutation, MutationCtx } from "./_generated/server.js";
3
+ import { createLogger, DEFAULT_LOG_LEVEL } from "./logging.js";
4
+ import { INITIAL_STATE } from "./loop.js";
5
+ import { Config, nextSegment } from "./shared.js";
6
+
7
+ export const DEFAULT_MAX_PARALLELISM = 10;
8
+ /**
9
+ * Called from outside the loop:
10
+ */
11
+
12
+ export async function kickMainLoop(
13
+ ctx: MutationCtx,
14
+ source: "enqueue" | "cancel" | "complete",
15
+ config?: Partial<Config>
16
+ ): Promise<void> {
17
+ const globals = await getOrUpdateGlobals(ctx, config);
18
+ const console = createLogger(globals.logLevel);
19
+ const runStatus = await getOrCreateRunStatus(ctx);
20
+
21
+ // Only kick to run now if we're scheduled or idle.
22
+ if (runStatus.state.kind === "running") {
23
+ console.debug(
24
+ `[${source}] main is actively running, so we don't need to kick it`
25
+ );
26
+ return;
27
+ }
28
+ const segment = nextSegment();
29
+ // main is scheduled to run later, so we should cancel it and reschedule.
30
+ if (runStatus.state.kind === "scheduled") {
31
+ if (source === "enqueue" && runStatus.state.saturated) {
32
+ console.debug(
33
+ `[${source}] main is saturated, so we don't need to kick it`
34
+ );
35
+ return;
36
+ }
37
+ if (runStatus.state.segment <= segment) {
38
+ console.debug(
39
+ `[${source}] main is scheduled to run soon enough, so we don't need to kick it`
40
+ );
41
+ return;
42
+ }
43
+ console.debug(
44
+ `[${source}] main is scheduled to run later, so reschedule it to run now`
45
+ );
46
+ const scheduled = await ctx.db.system.get(runStatus.state.scheduledId);
47
+ if (scheduled && scheduled.state.kind === "pending") {
48
+ await ctx.scheduler.cancel(runStatus.state.scheduledId);
49
+ } else {
50
+ console.warn(
51
+ `[${source}] main is marked as scheduled, but it's status is ${scheduled?.state.kind}`
52
+ );
53
+ }
54
+ }
55
+ console.debug(
56
+ `[${source}] main was scheduled later, so reschedule it to run now`
57
+ );
58
+ await ctx.db.patch(runStatus._id, { state: { kind: "running" } });
59
+ await ctx.scheduler.runAfter(0, internal.loop.main, {
60
+ generation: runStatus.state.generation,
61
+ segment,
62
+ });
63
+ }
64
+
65
+ export const forceKick = internalMutation({
66
+ args: {},
67
+ handler: async (ctx) => {
68
+ const runStatus = await getOrCreateRunStatus(ctx);
69
+ await ctx.db.delete(runStatus._id);
70
+ await kickMainLoop(ctx, "complete");
71
+ },
72
+ });
73
+
74
+ async function getOrCreateRunStatus(ctx: MutationCtx) {
75
+ let runStatus = await ctx.db.query("runStatus").unique();
76
+ if (!runStatus) {
77
+ const state = await ctx.db.query("internalState").unique();
78
+ const id = await ctx.db.insert("runStatus", {
79
+ state: {
80
+ kind: "idle",
81
+ generation: state?.generation ?? INITIAL_STATE.generation,
82
+ },
83
+ });
84
+ runStatus = (await ctx.db.get(id))!;
85
+ if (!state) {
86
+ await ctx.db.insert("internalState", INITIAL_STATE);
87
+ }
88
+ }
89
+ return runStatus;
90
+ }
91
+
92
+ async function getOrUpdateGlobals(ctx: MutationCtx, config?: Partial<Config>) {
93
+ const globals = await ctx.db.query("globals").unique();
94
+ if (!globals) {
95
+ const id = await ctx.db.insert("globals", {
96
+ maxParallelism: config?.maxParallelism ?? DEFAULT_MAX_PARALLELISM,
97
+ logLevel: config?.logLevel ?? DEFAULT_LOG_LEVEL,
98
+ });
99
+ return (await ctx.db.get(id))!;
100
+ } else if (config) {
101
+ let updated = false;
102
+ if (
103
+ config.maxParallelism &&
104
+ config.maxParallelism !== globals.maxParallelism
105
+ ) {
106
+ globals.maxParallelism = config.maxParallelism;
107
+ updated = true;
108
+ }
109
+ if (config.logLevel && config.logLevel !== globals.logLevel) {
110
+ globals.logLevel = config.logLevel;
111
+ updated = true;
112
+ }
113
+ if (updated) {
114
+ await ctx.db.replace(globals._id, globals);
115
+ }
116
+ }
117
+ return globals;
118
+ }