@electric-ax/agents-server 0.3.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 +177 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/entrypoint.js +7319 -0
- package/dist/index.cjs +7090 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4263 -0
- package/dist/index.js +7053 -0
- package/drizzle/0000_baseline.sql +97 -0
- package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
- package/drizzle/0002_tag_outbox_hardening.sql +14 -0
- package/drizzle/0003_entity_manifest_sources.sql +11 -0
- package/drizzle/0004_tenant_scoping.sql +139 -0
- package/drizzle/0005_pull_wake_control_plane.sql +156 -0
- package/drizzle/meta/0000_snapshot.json +593 -0
- package/drizzle/meta/_journal.json +48 -0
- package/package.json +89 -0
- package/src/authenticated-user-format.ts +17 -0
- package/src/claim-write-token-store.ts +74 -0
- package/src/db/index.ts +53 -0
- package/src/db/schema.ts +490 -0
- package/src/dev-asserted-auth.ts +46 -0
- package/src/dispatch-policy-schema.ts +52 -0
- package/src/electric-agents/adapter-types.ts +70 -0
- package/src/electric-agents/default-entity-schemas.ts +1 -0
- package/src/electric-agents/schema-validator.ts +143 -0
- package/src/electric-agents-http.ts +46 -0
- package/src/electric-agents-types.ts +335 -0
- package/src/entity-bridge-manager.ts +694 -0
- package/src/entity-manager.ts +2601 -0
- package/src/entity-projector.ts +765 -0
- package/src/entity-registry.ts +1162 -0
- package/src/entrypoint-lib.ts +295 -0
- package/src/entrypoint.ts +11 -0
- package/src/host.ts +323 -0
- package/src/index.ts +49 -0
- package/src/manifest-side-effects.ts +183 -0
- package/src/routing/agent-ui-router.ts +81 -0
- package/src/routing/context.ts +35 -0
- package/src/routing/cron-router.ts +45 -0
- package/src/routing/dispatch-policy.ts +248 -0
- package/src/routing/durable-streams-router.ts +407 -0
- package/src/routing/durable-streams-routing-adapter.ts +96 -0
- package/src/routing/electric-proxy-router.ts +61 -0
- package/src/routing/entities-router.ts +484 -0
- package/src/routing/entity-types-router.ts +229 -0
- package/src/routing/global-router.ts +33 -0
- package/src/routing/hooks.ts +123 -0
- package/src/routing/internal-router.ts +741 -0
- package/src/routing/oss-server-router.ts +56 -0
- package/src/routing/runners-router.ts +416 -0
- package/src/routing/schema.ts +141 -0
- package/src/routing/stream-append.ts +196 -0
- package/src/routing/tenant-stream-paths.ts +26 -0
- package/src/runtime-registry.ts +49 -0
- package/src/runtime.ts +537 -0
- package/src/scheduler.ts +788 -0
- package/src/schema-validation.ts +15 -0
- package/src/server.ts +374 -0
- package/src/standalone-runtime.ts +188 -0
- package/src/stream-client.ts +842 -0
- package/src/tag-stream-outbox-drainer.ts +188 -0
- package/src/tenant.ts +25 -0
- package/src/tracing.ts +57 -0
- package/src/utils/electric-url.ts +15 -0
- package/src/utils/log.ts +95 -0
- package/src/utils/server-utils.ts +245 -0
- package/src/utils/webhook-url.ts +33 -0
- package/src/wake-registry.ts +946 -0
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import { getNextCronFireAt } from '@electric-ax/agents-runtime'
|
|
2
|
+
import { DEFAULT_TENANT_ID, isUnregisteredTenantError } from './tenant.js'
|
|
3
|
+
import { serverLog } from './utils/log.js'
|
|
4
|
+
import type { PgClient } from './db/index.js'
|
|
5
|
+
|
|
6
|
+
export interface DelayedSendPayload {
|
|
7
|
+
entityUrl: string
|
|
8
|
+
from?: string
|
|
9
|
+
payload: unknown
|
|
10
|
+
key?: string
|
|
11
|
+
type?: string
|
|
12
|
+
producerId?: string
|
|
13
|
+
manifest?: {
|
|
14
|
+
ownerEntityUrl: string
|
|
15
|
+
key: string
|
|
16
|
+
entry: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CronTickPayload {
|
|
21
|
+
streamPath: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type SchedulerTaskKind = `delayed_send` | `cron_tick`
|
|
25
|
+
type TenantIdsProvider = () => Iterable<string>
|
|
26
|
+
const POSTGRES_TEXT_OID = 25
|
|
27
|
+
|
|
28
|
+
interface ScheduledTaskRow {
|
|
29
|
+
id: number | string
|
|
30
|
+
tenant_id: string
|
|
31
|
+
kind: SchedulerTaskKind
|
|
32
|
+
payload: DelayedSendPayload | CronTickPayload
|
|
33
|
+
fire_at: Date | string
|
|
34
|
+
cron_expression: string | null
|
|
35
|
+
cron_timezone: string | null
|
|
36
|
+
cron_tick_number: number | null
|
|
37
|
+
owner_entity_url: string | null
|
|
38
|
+
manifest_key: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SchedulerOptions {
|
|
42
|
+
pgClient: PgClient
|
|
43
|
+
instanceId: string
|
|
44
|
+
tenantId?: string | null
|
|
45
|
+
tenantIds?: TenantIdsProvider
|
|
46
|
+
claimExpiryMs?: number
|
|
47
|
+
safetyPollMs?: number
|
|
48
|
+
listen?: boolean
|
|
49
|
+
executors: {
|
|
50
|
+
delayed_send: (
|
|
51
|
+
payload: DelayedSendPayload,
|
|
52
|
+
taskId: number,
|
|
53
|
+
tenantId: string
|
|
54
|
+
) => Promise<void>
|
|
55
|
+
cron_tick: (
|
|
56
|
+
payload: CronTickPayload,
|
|
57
|
+
tickNumber: number,
|
|
58
|
+
taskId: number,
|
|
59
|
+
tenantId: string
|
|
60
|
+
) => Promise<void>
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SchedulerClient {
|
|
65
|
+
enqueueDelayedSend(
|
|
66
|
+
payload: DelayedSendPayload,
|
|
67
|
+
fireAt: Date,
|
|
68
|
+
opts?: { ownerEntityUrl?: string; manifestKey?: string }
|
|
69
|
+
): Promise<void>
|
|
70
|
+
syncManifestDelayedSend(
|
|
71
|
+
ownerEntityUrl: string,
|
|
72
|
+
manifestKey: string,
|
|
73
|
+
payload: DelayedSendPayload,
|
|
74
|
+
fireAt: Date
|
|
75
|
+
): Promise<void>
|
|
76
|
+
cancelManifestDelayedSend(
|
|
77
|
+
ownerEntityUrl: string,
|
|
78
|
+
manifestKey: string
|
|
79
|
+
): Promise<void>
|
|
80
|
+
enqueueCronTick(
|
|
81
|
+
expression: string,
|
|
82
|
+
timezone: string,
|
|
83
|
+
tickNumber: number,
|
|
84
|
+
streamPath: string,
|
|
85
|
+
fireAt: Date
|
|
86
|
+
): Promise<void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class PostgresSchedulerClient implements SchedulerClient {
|
|
90
|
+
constructor(
|
|
91
|
+
private readonly pgClient: PgClient,
|
|
92
|
+
private readonly tenantId: string,
|
|
93
|
+
private readonly wake?: () => void
|
|
94
|
+
) {}
|
|
95
|
+
|
|
96
|
+
async enqueueDelayedSend(
|
|
97
|
+
payload: DelayedSendPayload,
|
|
98
|
+
fireAt: Date,
|
|
99
|
+
opts?: { ownerEntityUrl?: string; manifestKey?: string }
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
await this.pgClient`
|
|
102
|
+
insert into scheduled_tasks (
|
|
103
|
+
tenant_id,
|
|
104
|
+
kind,
|
|
105
|
+
payload,
|
|
106
|
+
fire_at,
|
|
107
|
+
owner_entity_url,
|
|
108
|
+
manifest_key
|
|
109
|
+
)
|
|
110
|
+
values (
|
|
111
|
+
${this.tenantId},
|
|
112
|
+
'delayed_send',
|
|
113
|
+
${JSON.stringify(payload)}::jsonb,
|
|
114
|
+
${fireAt.toISOString()}::timestamptz,
|
|
115
|
+
${opts?.ownerEntityUrl ?? null},
|
|
116
|
+
${opts?.manifestKey ?? null}
|
|
117
|
+
)
|
|
118
|
+
`
|
|
119
|
+
this.wake?.()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async syncManifestDelayedSend(
|
|
123
|
+
ownerEntityUrl: string,
|
|
124
|
+
manifestKey: string,
|
|
125
|
+
payload: DelayedSendPayload,
|
|
126
|
+
fireAt: Date
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
await this.pgClient.begin(async (sql) => {
|
|
129
|
+
await sql`
|
|
130
|
+
update scheduled_tasks
|
|
131
|
+
set completed_at = now(), claimed_at = null, claimed_by = null
|
|
132
|
+
where tenant_id = ${this.tenantId}
|
|
133
|
+
and kind = 'delayed_send'
|
|
134
|
+
and owner_entity_url = ${ownerEntityUrl}
|
|
135
|
+
and manifest_key = ${manifestKey}
|
|
136
|
+
and completed_at is null
|
|
137
|
+
`
|
|
138
|
+
|
|
139
|
+
await sql`
|
|
140
|
+
insert into scheduled_tasks (
|
|
141
|
+
tenant_id,
|
|
142
|
+
kind,
|
|
143
|
+
payload,
|
|
144
|
+
fire_at,
|
|
145
|
+
owner_entity_url,
|
|
146
|
+
manifest_key
|
|
147
|
+
)
|
|
148
|
+
values (
|
|
149
|
+
${this.tenantId},
|
|
150
|
+
'delayed_send',
|
|
151
|
+
${JSON.stringify(payload)}::jsonb,
|
|
152
|
+
${fireAt.toISOString()}::timestamptz,
|
|
153
|
+
${ownerEntityUrl},
|
|
154
|
+
${manifestKey}
|
|
155
|
+
)
|
|
156
|
+
`
|
|
157
|
+
})
|
|
158
|
+
this.wake?.()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async cancelManifestDelayedSend(
|
|
162
|
+
ownerEntityUrl: string,
|
|
163
|
+
manifestKey: string
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
await this.pgClient`
|
|
166
|
+
update scheduled_tasks
|
|
167
|
+
set completed_at = now(), claimed_at = null, claimed_by = null
|
|
168
|
+
where tenant_id = ${this.tenantId}
|
|
169
|
+
and kind = 'delayed_send'
|
|
170
|
+
and owner_entity_url = ${ownerEntityUrl}
|
|
171
|
+
and manifest_key = ${manifestKey}
|
|
172
|
+
and completed_at is null
|
|
173
|
+
`
|
|
174
|
+
this.wake?.()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async enqueueCronTick(
|
|
178
|
+
expression: string,
|
|
179
|
+
timezone: string,
|
|
180
|
+
tickNumber: number,
|
|
181
|
+
streamPath: string,
|
|
182
|
+
fireAt: Date
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
await this.pgClient`
|
|
185
|
+
insert into scheduled_tasks (
|
|
186
|
+
tenant_id,
|
|
187
|
+
kind,
|
|
188
|
+
payload,
|
|
189
|
+
fire_at,
|
|
190
|
+
cron_expression,
|
|
191
|
+
cron_timezone,
|
|
192
|
+
cron_tick_number
|
|
193
|
+
)
|
|
194
|
+
values (
|
|
195
|
+
${this.tenantId},
|
|
196
|
+
'cron_tick',
|
|
197
|
+
${JSON.stringify({ streamPath })}::jsonb,
|
|
198
|
+
${fireAt.toISOString()}::timestamptz,
|
|
199
|
+
${expression},
|
|
200
|
+
${timezone},
|
|
201
|
+
${tickNumber}
|
|
202
|
+
)
|
|
203
|
+
on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
|
|
204
|
+
`
|
|
205
|
+
this.wake?.()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function isPermanentElectricAgentsError(err: unknown): boolean {
|
|
210
|
+
const status =
|
|
211
|
+
typeof err === `object` && err !== null && `status` in err
|
|
212
|
+
? (err as { status?: unknown }).status
|
|
213
|
+
: undefined
|
|
214
|
+
const name =
|
|
215
|
+
typeof err === `object` && err !== null && `name` in err
|
|
216
|
+
? (err as { name?: unknown }).name
|
|
217
|
+
: undefined
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
name === `ElectricAgentsError` &&
|
|
221
|
+
typeof status === `number` &&
|
|
222
|
+
status >= 400 &&
|
|
223
|
+
status < 500
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeTask(row: ScheduledTaskRow): {
|
|
228
|
+
id: number
|
|
229
|
+
tenantId: string
|
|
230
|
+
kind: SchedulerTaskKind
|
|
231
|
+
payload: DelayedSendPayload | CronTickPayload
|
|
232
|
+
fireAt: Date
|
|
233
|
+
cronExpression: string | null
|
|
234
|
+
cronTimezone: string | null
|
|
235
|
+
cronTickNumber: number | null
|
|
236
|
+
ownerEntityUrl: string | null
|
|
237
|
+
manifestKey: string | null
|
|
238
|
+
} {
|
|
239
|
+
return {
|
|
240
|
+
id: Number(row.id),
|
|
241
|
+
tenantId: row.tenant_id,
|
|
242
|
+
kind: row.kind,
|
|
243
|
+
payload: row.payload,
|
|
244
|
+
fireAt: row.fire_at instanceof Date ? row.fire_at : new Date(row.fire_at),
|
|
245
|
+
cronExpression: row.cron_expression,
|
|
246
|
+
cronTimezone: row.cron_timezone,
|
|
247
|
+
cronTickNumber: row.cron_tick_number,
|
|
248
|
+
ownerEntityUrl: row.owner_entity_url,
|
|
249
|
+
manifestKey: row.manifest_key,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export class Scheduler implements SchedulerClient {
|
|
254
|
+
private readonly claimExpiryMs: number
|
|
255
|
+
private readonly safetyPollMs: number
|
|
256
|
+
private readonly listenEnabled: boolean
|
|
257
|
+
private readonly pgClient: PgClient
|
|
258
|
+
private readonly instanceId: string
|
|
259
|
+
private readonly tenantId: string | null
|
|
260
|
+
private readonly tenantIds?: TenantIdsProvider
|
|
261
|
+
private running = false
|
|
262
|
+
private loopPromise: Promise<void> | null = null
|
|
263
|
+
private currentSleepResolve: (() => void) | null = null
|
|
264
|
+
private currentSleepTimer: NodeJS.Timeout | null = null
|
|
265
|
+
private listenerMeta: { unlisten: () => Promise<void> } | null = null
|
|
266
|
+
|
|
267
|
+
constructor(private readonly options: SchedulerOptions) {
|
|
268
|
+
this.pgClient = options.pgClient
|
|
269
|
+
this.instanceId = options.instanceId
|
|
270
|
+
this.tenantId =
|
|
271
|
+
options.tenantId === undefined ? DEFAULT_TENANT_ID : options.tenantId
|
|
272
|
+
this.tenantIds = options.tenantIds
|
|
273
|
+
this.claimExpiryMs = options.claimExpiryMs ?? 30_000
|
|
274
|
+
this.safetyPollMs = options.safetyPollMs ?? 10_000
|
|
275
|
+
this.listenEnabled = options.listen !== false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private resolveTenantId(tenantId?: string): string {
|
|
279
|
+
if (tenantId) return tenantId
|
|
280
|
+
if (this.tenantId) return this.tenantId
|
|
281
|
+
throw new Error(`Scheduler tenantId is required in shared mode`)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async start(): Promise<void> {
|
|
285
|
+
if (this.running) return
|
|
286
|
+
this.running = true
|
|
287
|
+
|
|
288
|
+
if (this.listenEnabled) {
|
|
289
|
+
this.listenerMeta = await this.pgClient.listen(
|
|
290
|
+
`scheduled_tasks_wake`,
|
|
291
|
+
() => {
|
|
292
|
+
this.wakeEarly()
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.loopPromise = this.runLoop().catch((err) => {
|
|
298
|
+
console.error(`[agent-server] scheduler loop failed:`, err)
|
|
299
|
+
this.running = false
|
|
300
|
+
this.wakeEarly()
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async stop(): Promise<void> {
|
|
305
|
+
this.running = false
|
|
306
|
+
this.wakeEarly()
|
|
307
|
+
if (this.loopPromise) {
|
|
308
|
+
await this.loopPromise
|
|
309
|
+
this.loopPromise = null
|
|
310
|
+
}
|
|
311
|
+
if (this.listenerMeta) {
|
|
312
|
+
await this.listenerMeta.unlisten()
|
|
313
|
+
this.listenerMeta = null
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
wake(): void {
|
|
318
|
+
this.wakeEarly()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async enqueueDelayedSend(
|
|
322
|
+
payload: DelayedSendPayload,
|
|
323
|
+
fireAt: Date,
|
|
324
|
+
opts?: { ownerEntityUrl?: string; manifestKey?: string }
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const tenantId = this.resolveTenantId()
|
|
327
|
+
await this.pgClient`
|
|
328
|
+
insert into scheduled_tasks (
|
|
329
|
+
tenant_id,
|
|
330
|
+
kind,
|
|
331
|
+
payload,
|
|
332
|
+
fire_at,
|
|
333
|
+
owner_entity_url,
|
|
334
|
+
manifest_key
|
|
335
|
+
)
|
|
336
|
+
values (
|
|
337
|
+
${tenantId},
|
|
338
|
+
'delayed_send',
|
|
339
|
+
${JSON.stringify(payload)}::jsonb,
|
|
340
|
+
${fireAt.toISOString()}::timestamptz,
|
|
341
|
+
${opts?.ownerEntityUrl ?? null},
|
|
342
|
+
${opts?.manifestKey ?? null}
|
|
343
|
+
)
|
|
344
|
+
`
|
|
345
|
+
this.wakeEarly()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async syncManifestDelayedSend(
|
|
349
|
+
ownerEntityUrl: string,
|
|
350
|
+
manifestKey: string,
|
|
351
|
+
payload: DelayedSendPayload,
|
|
352
|
+
fireAt: Date
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
const tenantId = this.resolveTenantId()
|
|
355
|
+
await this.pgClient.begin(async (sql) => {
|
|
356
|
+
await sql`
|
|
357
|
+
update scheduled_tasks
|
|
358
|
+
set completed_at = now(), claimed_at = null, claimed_by = null
|
|
359
|
+
where tenant_id = ${tenantId}
|
|
360
|
+
and kind = 'delayed_send'
|
|
361
|
+
and owner_entity_url = ${ownerEntityUrl}
|
|
362
|
+
and manifest_key = ${manifestKey}
|
|
363
|
+
and completed_at is null
|
|
364
|
+
`
|
|
365
|
+
|
|
366
|
+
await sql`
|
|
367
|
+
insert into scheduled_tasks (
|
|
368
|
+
tenant_id,
|
|
369
|
+
kind,
|
|
370
|
+
payload,
|
|
371
|
+
fire_at,
|
|
372
|
+
owner_entity_url,
|
|
373
|
+
manifest_key
|
|
374
|
+
)
|
|
375
|
+
values (
|
|
376
|
+
${tenantId},
|
|
377
|
+
'delayed_send',
|
|
378
|
+
${JSON.stringify(payload)}::jsonb,
|
|
379
|
+
${fireAt.toISOString()}::timestamptz,
|
|
380
|
+
${ownerEntityUrl},
|
|
381
|
+
${manifestKey}
|
|
382
|
+
)
|
|
383
|
+
`
|
|
384
|
+
})
|
|
385
|
+
this.wakeEarly()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async cancelManifestDelayedSend(
|
|
389
|
+
ownerEntityUrl: string,
|
|
390
|
+
manifestKey: string
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
const tenantId = this.resolveTenantId()
|
|
393
|
+
await this.pgClient`
|
|
394
|
+
update scheduled_tasks
|
|
395
|
+
set completed_at = now(), claimed_at = null, claimed_by = null
|
|
396
|
+
where tenant_id = ${tenantId}
|
|
397
|
+
and kind = 'delayed_send'
|
|
398
|
+
and owner_entity_url = ${ownerEntityUrl}
|
|
399
|
+
and manifest_key = ${manifestKey}
|
|
400
|
+
and completed_at is null
|
|
401
|
+
`
|
|
402
|
+
this.wakeEarly()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async enqueueCronTick(
|
|
406
|
+
expression: string,
|
|
407
|
+
timezone: string,
|
|
408
|
+
tickNumber: number,
|
|
409
|
+
streamPath: string,
|
|
410
|
+
fireAt: Date
|
|
411
|
+
): Promise<void> {
|
|
412
|
+
const tenantId = this.resolveTenantId()
|
|
413
|
+
await this.pgClient`
|
|
414
|
+
insert into scheduled_tasks (
|
|
415
|
+
tenant_id,
|
|
416
|
+
kind,
|
|
417
|
+
payload,
|
|
418
|
+
fire_at,
|
|
419
|
+
cron_expression,
|
|
420
|
+
cron_timezone,
|
|
421
|
+
cron_tick_number
|
|
422
|
+
)
|
|
423
|
+
values (
|
|
424
|
+
${tenantId},
|
|
425
|
+
'cron_tick',
|
|
426
|
+
${JSON.stringify({ streamPath })}::jsonb,
|
|
427
|
+
${fireAt.toISOString()}::timestamptz,
|
|
428
|
+
${expression},
|
|
429
|
+
${timezone},
|
|
430
|
+
${tickNumber}
|
|
431
|
+
)
|
|
432
|
+
on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
|
|
433
|
+
`
|
|
434
|
+
this.wakeEarly()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private async runLoop(): Promise<void> {
|
|
438
|
+
while (this.running) {
|
|
439
|
+
try {
|
|
440
|
+
await this.reclaimStaleClaims()
|
|
441
|
+
await this.fireReadyTasks()
|
|
442
|
+
|
|
443
|
+
const nextFireAt = await this.getNextFireAt()
|
|
444
|
+
const sleepTargetMs = nextFireAt
|
|
445
|
+
? Math.max(0, nextFireAt.getTime() - Date.now())
|
|
446
|
+
: this.safetyPollMs
|
|
447
|
+
|
|
448
|
+
await this.sleepOrWake(Math.min(sleepTargetMs, this.safetyPollMs))
|
|
449
|
+
} catch (err) {
|
|
450
|
+
console.error(`[agent-server] scheduler iteration failed:`, err)
|
|
451
|
+
await this.sleepOrWake(this.safetyPollMs)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async reclaimStaleClaims(): Promise<void> {
|
|
457
|
+
if (this.tenantId === null) {
|
|
458
|
+
const tenantIds = this.sharedTenantIds()
|
|
459
|
+
if (tenantIds && tenantIds.length === 0) return
|
|
460
|
+
if (tenantIds) {
|
|
461
|
+
await this.pgClient`
|
|
462
|
+
update scheduled_tasks
|
|
463
|
+
set claimed_by = null, claimed_at = null
|
|
464
|
+
where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
|
|
465
|
+
and completed_at is null
|
|
466
|
+
and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
|
|
467
|
+
`
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await this.pgClient`
|
|
472
|
+
update scheduled_tasks
|
|
473
|
+
set claimed_by = null, claimed_at = null
|
|
474
|
+
where completed_at is null
|
|
475
|
+
and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
|
|
476
|
+
`
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
await this.pgClient`
|
|
481
|
+
update scheduled_tasks
|
|
482
|
+
set claimed_by = null, claimed_at = null
|
|
483
|
+
where tenant_id = ${this.tenantId}
|
|
484
|
+
and completed_at is null
|
|
485
|
+
and claimed_at < now() - (${this.claimExpiryMs} * interval '1 millisecond')
|
|
486
|
+
`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async fireReadyTasks(): Promise<void> {
|
|
490
|
+
while (this.running) {
|
|
491
|
+
const tasks = await this.claimReadyTasks()
|
|
492
|
+
if (tasks.length === 0) return
|
|
493
|
+
|
|
494
|
+
for (const task of tasks) {
|
|
495
|
+
await this.executeTask(task)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private async claimReadyTasks(): Promise<
|
|
501
|
+
Array<ReturnType<typeof normalizeTask>>
|
|
502
|
+
> {
|
|
503
|
+
if (this.tenantId === null) {
|
|
504
|
+
const tenantIds = this.sharedTenantIds()
|
|
505
|
+
if (tenantIds && tenantIds.length === 0) return []
|
|
506
|
+
if (tenantIds) {
|
|
507
|
+
const rows = await this.pgClient<Array<ScheduledTaskRow>>`
|
|
508
|
+
update scheduled_tasks
|
|
509
|
+
set claimed_by = ${this.instanceId}, claimed_at = now()
|
|
510
|
+
where id in (
|
|
511
|
+
select id
|
|
512
|
+
from scheduled_tasks
|
|
513
|
+
where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
|
|
514
|
+
and completed_at is null
|
|
515
|
+
and claimed_at is null
|
|
516
|
+
and fire_at <= now()
|
|
517
|
+
order by fire_at, id
|
|
518
|
+
for update skip locked
|
|
519
|
+
limit 50
|
|
520
|
+
)
|
|
521
|
+
returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
|
|
522
|
+
, owner_entity_url, manifest_key
|
|
523
|
+
`
|
|
524
|
+
|
|
525
|
+
return rows.map(normalizeTask)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const rows = await this.pgClient<Array<ScheduledTaskRow>>`
|
|
529
|
+
update scheduled_tasks
|
|
530
|
+
set claimed_by = ${this.instanceId}, claimed_at = now()
|
|
531
|
+
where id in (
|
|
532
|
+
select id
|
|
533
|
+
from scheduled_tasks
|
|
534
|
+
where completed_at is null
|
|
535
|
+
and claimed_at is null
|
|
536
|
+
and fire_at <= now()
|
|
537
|
+
order by fire_at, id
|
|
538
|
+
for update skip locked
|
|
539
|
+
limit 50
|
|
540
|
+
)
|
|
541
|
+
returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
|
|
542
|
+
, owner_entity_url, manifest_key
|
|
543
|
+
`
|
|
544
|
+
|
|
545
|
+
return rows.map(normalizeTask)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const rows = await this.pgClient<Array<ScheduledTaskRow>>`
|
|
549
|
+
update scheduled_tasks
|
|
550
|
+
set claimed_by = ${this.instanceId}, claimed_at = now()
|
|
551
|
+
where tenant_id = ${this.tenantId}
|
|
552
|
+
and id in (
|
|
553
|
+
select id
|
|
554
|
+
from scheduled_tasks
|
|
555
|
+
where tenant_id = ${this.tenantId}
|
|
556
|
+
and completed_at is null
|
|
557
|
+
and claimed_at is null
|
|
558
|
+
and fire_at <= now()
|
|
559
|
+
order by fire_at, id
|
|
560
|
+
for update skip locked
|
|
561
|
+
limit 50
|
|
562
|
+
)
|
|
563
|
+
returning tenant_id, id, kind, payload, fire_at, cron_expression, cron_timezone, cron_tick_number
|
|
564
|
+
, owner_entity_url, manifest_key
|
|
565
|
+
`
|
|
566
|
+
|
|
567
|
+
return rows.map(normalizeTask)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async executeTask(
|
|
571
|
+
task: ReturnType<typeof normalizeTask>
|
|
572
|
+
): Promise<void> {
|
|
573
|
+
try {
|
|
574
|
+
if (task.kind === `delayed_send`) {
|
|
575
|
+
await this.options.executors.delayed_send(
|
|
576
|
+
task.payload as DelayedSendPayload,
|
|
577
|
+
task.id,
|
|
578
|
+
task.tenantId
|
|
579
|
+
)
|
|
580
|
+
await this.markTaskComplete(task.id, task.tenantId)
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const tickNumber = task.cronTickNumber
|
|
585
|
+
if (tickNumber == null || !task.cronExpression || !task.cronTimezone) {
|
|
586
|
+
throw new Error(`cron task ${task.id} is missing cron metadata`)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await this.options.executors.cron_tick(
|
|
590
|
+
task.payload as CronTickPayload,
|
|
591
|
+
tickNumber,
|
|
592
|
+
task.id,
|
|
593
|
+
task.tenantId
|
|
594
|
+
)
|
|
595
|
+
await this.completeAndRescheduleCron(task)
|
|
596
|
+
} catch (err) {
|
|
597
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
598
|
+
if (isUnregisteredTenantError(err)) {
|
|
599
|
+
await this.releaseClaim(task.id, message, task.tenantId)
|
|
600
|
+
serverLog.warn(
|
|
601
|
+
`[scheduler] skipped ${task.kind} task ${task.id} for unregistered tenant "${task.tenantId}": ${message}`
|
|
602
|
+
)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
if (isPermanentElectricAgentsError(err)) {
|
|
606
|
+
await this.markTaskPermanentFailure(task.id, message, task.tenantId)
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
await this.releaseClaim(task.id, message, task.tenantId)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private async markTaskComplete(
|
|
614
|
+
taskId: number,
|
|
615
|
+
tenantId = this.resolveTenantId()
|
|
616
|
+
): Promise<void> {
|
|
617
|
+
await this.pgClient`
|
|
618
|
+
update scheduled_tasks
|
|
619
|
+
set completed_at = now(), last_error = null
|
|
620
|
+
where tenant_id = ${tenantId}
|
|
621
|
+
and id = ${taskId}
|
|
622
|
+
and claimed_by = ${this.instanceId}
|
|
623
|
+
and completed_at is null
|
|
624
|
+
`
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private async markTaskPermanentFailure(
|
|
628
|
+
taskId: number,
|
|
629
|
+
message: string,
|
|
630
|
+
tenantId = this.resolveTenantId()
|
|
631
|
+
): Promise<void> {
|
|
632
|
+
await this.pgClient`
|
|
633
|
+
update scheduled_tasks
|
|
634
|
+
set completed_at = now(), last_error = ${message}
|
|
635
|
+
where tenant_id = ${tenantId}
|
|
636
|
+
and id = ${taskId}
|
|
637
|
+
and claimed_by = ${this.instanceId}
|
|
638
|
+
and completed_at is null
|
|
639
|
+
`
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private async releaseClaim(
|
|
643
|
+
taskId: number,
|
|
644
|
+
message: string,
|
|
645
|
+
tenantId = this.resolveTenantId()
|
|
646
|
+
): Promise<void> {
|
|
647
|
+
await this.pgClient`
|
|
648
|
+
update scheduled_tasks
|
|
649
|
+
set claimed_at = null, claimed_by = null, last_error = ${message}
|
|
650
|
+
where tenant_id = ${tenantId}
|
|
651
|
+
and id = ${taskId}
|
|
652
|
+
and claimed_by = ${this.instanceId}
|
|
653
|
+
and completed_at is null
|
|
654
|
+
`
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private async completeAndRescheduleCron(
|
|
658
|
+
task: ReturnType<typeof normalizeTask>
|
|
659
|
+
): Promise<void> {
|
|
660
|
+
const tenantId = task.tenantId ?? this.resolveTenantId()
|
|
661
|
+
await this.pgClient.begin(async (sql) => {
|
|
662
|
+
const completed = await sql<Array<{ id: number | string }>>`
|
|
663
|
+
update scheduled_tasks
|
|
664
|
+
set completed_at = now(), last_error = null
|
|
665
|
+
where tenant_id = ${tenantId}
|
|
666
|
+
and id = ${task.id}
|
|
667
|
+
and claimed_by = ${this.instanceId}
|
|
668
|
+
and completed_at is null
|
|
669
|
+
returning id
|
|
670
|
+
`
|
|
671
|
+
if (completed.length === 0) return
|
|
672
|
+
|
|
673
|
+
const nextFireAt = getNextCronFireAt(
|
|
674
|
+
task.cronExpression!,
|
|
675
|
+
task.cronTimezone!,
|
|
676
|
+
task.fireAt
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
await sql`
|
|
680
|
+
insert into scheduled_tasks (
|
|
681
|
+
tenant_id,
|
|
682
|
+
kind,
|
|
683
|
+
payload,
|
|
684
|
+
fire_at,
|
|
685
|
+
cron_expression,
|
|
686
|
+
cron_timezone,
|
|
687
|
+
cron_tick_number
|
|
688
|
+
)
|
|
689
|
+
values (
|
|
690
|
+
${tenantId},
|
|
691
|
+
'cron_tick',
|
|
692
|
+
${JSON.stringify(task.payload)}::jsonb,
|
|
693
|
+
${nextFireAt.toISOString()}::timestamptz,
|
|
694
|
+
${task.cronExpression},
|
|
695
|
+
${task.cronTimezone},
|
|
696
|
+
${task.cronTickNumber! + 1}
|
|
697
|
+
)
|
|
698
|
+
on conflict (tenant_id, cron_expression, cron_timezone, cron_tick_number) do nothing
|
|
699
|
+
`
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async getNextFireAt(): Promise<Date | null> {
|
|
704
|
+
if (this.tenantId === null) {
|
|
705
|
+
const tenantIds = this.sharedTenantIds()
|
|
706
|
+
if (tenantIds && tenantIds.length === 0) return null
|
|
707
|
+
if (tenantIds) {
|
|
708
|
+
const rows = await this.pgClient<Array<{ fire_at: Date | string }>>`
|
|
709
|
+
select fire_at
|
|
710
|
+
from scheduled_tasks
|
|
711
|
+
where tenant_id = any(${this.sharedTenantIdsParameter(tenantIds)})
|
|
712
|
+
and completed_at is null
|
|
713
|
+
and claimed_at is null
|
|
714
|
+
order by fire_at, id
|
|
715
|
+
limit 1
|
|
716
|
+
`
|
|
717
|
+
|
|
718
|
+
if (rows.length === 0) return null
|
|
719
|
+
const fireAt = rows[0]!.fire_at
|
|
720
|
+
return fireAt instanceof Date ? fireAt : new Date(fireAt)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const rows = await this.pgClient<Array<{ fire_at: Date | string }>>`
|
|
724
|
+
select fire_at
|
|
725
|
+
from scheduled_tasks
|
|
726
|
+
where completed_at is null
|
|
727
|
+
and claimed_at is null
|
|
728
|
+
order by fire_at, id
|
|
729
|
+
limit 1
|
|
730
|
+
`
|
|
731
|
+
|
|
732
|
+
if (rows.length === 0) return null
|
|
733
|
+
const fireAt = rows[0]!.fire_at
|
|
734
|
+
return fireAt instanceof Date ? fireAt : new Date(fireAt)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const rows = await this.pgClient<Array<{ fire_at: Date | string }>>`
|
|
738
|
+
select fire_at
|
|
739
|
+
from scheduled_tasks
|
|
740
|
+
where tenant_id = ${this.tenantId}
|
|
741
|
+
and completed_at is null
|
|
742
|
+
and claimed_at is null
|
|
743
|
+
order by fire_at, id
|
|
744
|
+
limit 1
|
|
745
|
+
`
|
|
746
|
+
|
|
747
|
+
if (rows.length === 0) return null
|
|
748
|
+
const fireAt = rows[0]!.fire_at
|
|
749
|
+
return fireAt instanceof Date ? fireAt : new Date(fireAt)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async sleepOrWake(durationMs: number): Promise<void> {
|
|
753
|
+
if (!this.running) return
|
|
754
|
+
|
|
755
|
+
await new Promise<void>((resolve) => {
|
|
756
|
+
const finish = (): void => {
|
|
757
|
+
if (this.currentSleepTimer) {
|
|
758
|
+
clearTimeout(this.currentSleepTimer)
|
|
759
|
+
this.currentSleepTimer = null
|
|
760
|
+
}
|
|
761
|
+
this.currentSleepResolve = null
|
|
762
|
+
resolve()
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
this.currentSleepResolve = finish
|
|
766
|
+
this.currentSleepTimer = setTimeout(finish, Math.max(durationMs, 0))
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private wakeEarly(): void {
|
|
771
|
+
const resolve = this.currentSleepResolve
|
|
772
|
+
this.currentSleepResolve = null
|
|
773
|
+
if (this.currentSleepTimer) {
|
|
774
|
+
clearTimeout(this.currentSleepTimer)
|
|
775
|
+
this.currentSleepTimer = null
|
|
776
|
+
}
|
|
777
|
+
resolve?.()
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private sharedTenantIds(): Array<string> | null {
|
|
781
|
+
if (this.tenantId !== null || !this.tenantIds) return null
|
|
782
|
+
return [...new Set(this.tenantIds())]
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private sharedTenantIdsParameter(tenantIds: Array<string>) {
|
|
786
|
+
return this.pgClient.array(tenantIds, POSTGRES_TEXT_OID)
|
|
787
|
+
}
|
|
788
|
+
}
|