@empyria/restate 0.4.5 → 0.4.6

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/AGENTS.md CHANGED
@@ -78,6 +78,13 @@ may observe an already-mocked value. See `test/Admin.test.js`.
78
78
  ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
79
79
  `process.exit()` — none of which are safe to exercise in-process in a test run.
80
80
 
81
+ ## Cron jobs
82
+
83
+ `CronJob.ensure` is the idempotent entry point (`ensureCronJob` is its ingress client). Never make a boot-time scheduler
84
+ with `CronJobInitiator.create`: it generates a new random job ID on every call, so each restart would add another copy.
85
+ `ensure` compares the normalised request (`cronExpression`, `service`, `method`, `key`, `payload`) and only reschedules
86
+ when it changed. A change here must keep `initiate`, `execute` and `cancel` behaving as before.
87
+
81
88
  ## Zero-dependency preference
82
89
 
83
90
  Cron parsing uses `croner` (zero dependencies), not `cron-parser` (pulls in `luxon`). If you
package/README.md CHANGED
@@ -106,6 +106,21 @@ export const triage = defineAgent({
106
106
  | [lib/agent/Errors.js](./lib/agent/Errors.js) | `toRestateLLMError` (AI SDK error → terminal / `RetryableError` / transient) and the default retry policies for LLM steps, tool steps and the agent invocation. |
107
107
  | [lib/agent/Model.js](./lib/agent/Model.js) | `createModel` — AI SDK model for an OpenAI-compatible endpoint (e.g. Bifrost). |
108
108
 
109
+ ### Recurring jobs
110
+
111
+ `cronJob` (a virtual object) runs a target handler on a cron schedule: each run sends the target, then schedules the
112
+ next one from the current time, so a restart or an outage never causes a backlog of runs. Register `cronJob` on your
113
+ endpoint, then create jobs with either:
114
+
115
+ - `cronJobInitiator.create(request)`: a new job with a random ID each call (good for jobs created by users);
116
+ - `ensureCronJob({restateURL, id, cronExpression, service, method, key?, payload?})`: **idempotent, for code that
117
+ starts with the application.** The job has the stable `id` you give it, so calling it on every start never adds a second
118
+ job: the same request changes nothing, and a changed request (another schedule, target or payload) replaces the pending
119
+ run under the same ID. Call it once the endpoint has registered, e.g. from `setupRestate`'s `registrationCallbackFn`.
120
+
121
+ Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
122
+ `payload` as its input, or no input when there is none.
123
+
109
124
  ### Shaping an agent
110
125
 
111
126
  `defineAgent` options beyond the basics:
package/lib/Cron.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as restate from '@restatedev/restate-sdk'
2
2
  import { TerminalError, serde } from '@restatedev/restate-sdk'
3
3
  import { Cron } from 'croner'
4
+ import { sendMessage } from './Admin.js'
4
5
 
5
6
  let JOB_STATE_NAME = 'empyria-job-state'
6
7
 
@@ -32,6 +33,24 @@ export const cronJob = restate.object({
32
33
 
33
34
  return await scheduleNextExecution(ctx, request)
34
35
  },
36
+ /**
37
+ * Makes sure this job exists and runs the given request: an idempotent `initiate`, meant for
38
+ * code that starts with the application and cannot know whether the job was created before.
39
+ *
40
+ * - no job yet: schedules it, as `initiate` does;
41
+ * - the same request already scheduled: changes nothing and returns the current state;
42
+ * - a different request (another schedule, target or payload): cancels the pending run and
43
+ * schedules the new request under the same job ID.
44
+ */
45
+ ensure: async (ctx, request) => {
46
+ const wanted = normalizeRequest(request)
47
+ const jobState = await ctx.get(JOB_STATE_NAME)
48
+
49
+ if (jobState && sameRequest(jobState.request, wanted)) return jobState
50
+ if (jobState) ctx.cancel(jobState.next_execution_id)
51
+
52
+ return await scheduleNextExecution(ctx, wanted)
53
+ },
35
54
  execute: async (ctx) => {
36
55
  const jobState = await ctx.get(JOB_STATE_NAME)
37
56
  if (!jobState) {
@@ -76,6 +95,25 @@ export const cronJob = restate.object({
76
95
  },
77
96
  })
78
97
 
98
+ const REQUEST_FIELDS = ['cronExpression', 'service', 'method', 'key', 'payload']
99
+
100
+ /** Keeps only the fields a job request has, and checks the required ones. */
101
+ function normalizeRequest(request) {
102
+ const out = {}
103
+ for (const field of REQUEST_FIELDS)
104
+ if (request?.[field] !== undefined) out[field] = request[field]
105
+ for (const field of ['cronExpression', 'service', 'method']) {
106
+ if (typeof out[field] !== 'string' || out[field].trim() === '') {
107
+ throw new TerminalError(`A cron job needs a non-empty "${field}".`, { errorCode: 400 })
108
+ }
109
+ }
110
+ return out
111
+ }
112
+
113
+ const sameRequest = (a, b) =>
114
+ JSON.stringify(REQUEST_FIELDS.map((f) => a?.[f] ?? null)) ===
115
+ JSON.stringify(REQUEST_FIELDS.map((f) => b?.[f] ?? null))
116
+
79
117
  const scheduleNextExecution = async (ctx, request) => {
80
118
  // Parse cron expression
81
119
  // Persist current date in Restate for deterministic replay
@@ -106,4 +144,30 @@ const scheduleNextExecution = async (ctx, request) => {
106
144
  return jobState
107
145
  }
108
146
 
147
+ /**
148
+ * Makes sure a recurring job exists, from outside Restate (typically right after the service registered).
149
+ * Safe to call on every start: the job has a stable ID, so a repeat call changes nothing and a changed
150
+ * request replaces the old schedule instead of adding a second job.
151
+ *
152
+ * Needs the `CronJob` object registered on an endpoint.
153
+ * @param {{restateURL: string, id: string, cronExpression: string, service: string, method: string, key?: string, payload?: any}} params
154
+ * `id` names the job (letters, digits, `.`, `_`, `:`, `-`); `service`/`method`/`key` are the target the
155
+ * job calls on each run, with `payload` as its input (none when omitted).
156
+ * @returns {Promise<{request: object, next_execution_time: string, next_execution_id: string}>}
157
+ */
158
+ export async function ensureCronJob({ restateURL, id, ...request }) {
159
+ if (typeof id !== 'string' || !/^[A-Za-z0-9._:-]+$/.test(id)) {
160
+ throw new TypeError(
161
+ 'ensureCronJob needs an id made of letters, digits, ".", "_", ":" and "-"',
162
+ )
163
+ }
164
+ return sendMessage({
165
+ restateURL,
166
+ name: 'CronJob',
167
+ message: 'ensure',
168
+ key: id,
169
+ payload: request,
170
+ })
171
+ }
172
+
109
173
  export { JOB_STATE_NAME }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
package/test/Cron.test.js CHANGED
@@ -1,5 +1,11 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import { cronJobInitiator, cronJob, setJobStateName, JOB_STATE_NAME } from '../lib/Cron.js'
1
+ import { afterEach, describe, test, expect } from 'bun:test'
2
+ import {
3
+ cronJobInitiator,
4
+ cronJob,
5
+ ensureCronJob,
6
+ setJobStateName,
7
+ JOB_STATE_NAME,
8
+ } from '../lib/Cron.js'
3
9
  import { TerminalError } from '@restatedev/restate-sdk'
4
10
 
5
11
  describe('setJobStateName', () => {
@@ -30,8 +36,11 @@ describe('cronJobInitiator.create', () => {
30
36
  // wiring; this factory keeps the mocked context consistent across tests.
31
37
  function makeSchedulingCtx({ existing } = {}) {
32
38
  const sets = []
39
+ const cancelled = []
33
40
  return {
34
41
  sets,
42
+ cancelled,
43
+ cancel: (invocationId) => cancelled.push(invocationId),
35
44
  get: async () => existing,
36
45
  date: { now: async () => Date.now() },
37
46
  key: 'job-1',
@@ -149,3 +158,136 @@ describe('cronJob.getInfo', () => {
149
158
  expect(await cronJob.object.getInfo(ctx)).toEqual({ request: {} })
150
159
  })
151
160
  })
161
+
162
+ describe('cronJob.ensure', () => {
163
+ const request = { cronExpression: '* * * * *', service: 'ReportService', method: 'refresh' }
164
+
165
+ test('schedules a job that does not exist yet', async () => {
166
+ const ctx = makeSchedulingCtx()
167
+ const state = await cronJob.object.ensure(ctx, request)
168
+ expect(state.request).toEqual(request)
169
+ expect(state.next_execution_id).toBe('exec-inv-1')
170
+ expect(ctx.sets).toEqual([[JOB_STATE_NAME, state]])
171
+ expect(ctx.cancelled).toEqual([])
172
+ })
173
+
174
+ test('the same request again changes nothing: no second schedule, no cancel', async () => {
175
+ const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
176
+ const ctx = makeSchedulingCtx({ existing })
177
+ expect(await cronJob.object.ensure(ctx, request)).toBe(existing)
178
+ expect(ctx.sets).toEqual([])
179
+ expect(ctx.cancelled).toEqual([])
180
+ })
181
+
182
+ test('unknown fields and field order do not make a request "different"', async () => {
183
+ const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
184
+ const ctx = makeSchedulingCtx({ existing })
185
+ const same = {
186
+ method: 'refresh',
187
+ extra: 'ignored',
188
+ service: 'ReportService',
189
+ cronExpression: '* * * * *',
190
+ }
191
+ expect(await cronJob.object.ensure(ctx, same)).toBe(existing)
192
+ expect(ctx.sets).toEqual([])
193
+ })
194
+
195
+ test('a changed schedule cancels the pending run and schedules the new one under the same job', async () => {
196
+ const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
197
+ const ctx = makeSchedulingCtx({ existing })
198
+ const state = await cronJob.object.ensure(ctx, {
199
+ ...request,
200
+ cronExpression: '*/5 * * * *',
201
+ })
202
+ expect(ctx.cancelled).toEqual(['inv-old'])
203
+ expect(state.request.cronExpression).toBe('*/5 * * * *')
204
+ expect(ctx.sets).toHaveLength(1)
205
+ })
206
+
207
+ test('a changed target or payload also replaces the job', async () => {
208
+ const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
209
+ for (const change of [{ method: 'other' }, { payload: { a: 1 } }, { key: 'k' }]) {
210
+ const ctx = makeSchedulingCtx({ existing })
211
+ await cronJob.object.ensure(ctx, { ...request, ...change })
212
+ expect(ctx.cancelled).toEqual(['inv-old'])
213
+ }
214
+ })
215
+
216
+ test('a request without a schedule, service or method is refused (400)', async () => {
217
+ for (const bad of [
218
+ {},
219
+ { ...request, cronExpression: '' },
220
+ { ...request, service: undefined },
221
+ { ...request, method: 3 },
222
+ ]) {
223
+ const error = await cronJob.object.ensure(makeSchedulingCtx(), bad).catch((e) => e)
224
+ expect(error).toBeInstanceOf(TerminalError)
225
+ expect(error.code).toBe(400)
226
+ }
227
+ })
228
+
229
+ test('an invalid cron expression is a terminal error and leaves an existing job alone', async () => {
230
+ const ctx = makeSchedulingCtx()
231
+ await expect(
232
+ cronJob.object.ensure(ctx, { ...request, cronExpression: 'not a cron' }),
233
+ ).rejects.toThrow(TerminalError)
234
+ expect(ctx.sets).toEqual([])
235
+ })
236
+ })
237
+
238
+ describe('ensureCronJob', () => {
239
+ const realFetch = globalThis.fetch
240
+ afterEach(() => {
241
+ globalThis.fetch = realFetch
242
+ })
243
+
244
+ test('calls CronJob/<id>/ensure through the ingress with the request as the body', async () => {
245
+ const calls = []
246
+ globalThis.fetch = async (url, init) => {
247
+ calls.push([url, JSON.parse(init.body)])
248
+ return new Response(JSON.stringify({ next_execution_time: 'T' }), { status: 200 })
249
+ }
250
+ const state = await ensureCronJob({
251
+ restateURL: 'http://restate:8080',
252
+ id: 'reports:refresh',
253
+ cronExpression: '* * * * *',
254
+ service: 'ReportService',
255
+ method: 'refresh',
256
+ })
257
+ expect(state).toEqual({ next_execution_time: 'T' })
258
+ expect(calls).toEqual([
259
+ [
260
+ 'http://restate:8080/CronJob/reports:refresh/ensure',
261
+ { cronExpression: '* * * * *', service: 'ReportService', method: 'refresh' },
262
+ ],
263
+ ])
264
+ })
265
+
266
+ test('refuses an id that is not safe in a URL path', async () => {
267
+ for (const id of ['', 'a/b', 'a b', 'a?b', undefined]) {
268
+ await expect(
269
+ ensureCronJob({
270
+ restateURL: 'http://x',
271
+ id,
272
+ cronExpression: '* * * * *',
273
+ service: 's',
274
+ method: 'm',
275
+ }),
276
+ ).rejects.toThrow(TypeError)
277
+ }
278
+ })
279
+
280
+ test('a failed call is an error for the caller', async () => {
281
+ globalThis.fetch = async () =>
282
+ new Response('nope', { status: 500, statusText: 'Internal Server Error' })
283
+ await expect(
284
+ ensureCronJob({
285
+ restateURL: 'http://x',
286
+ id: 'j',
287
+ cronExpression: '* * * * *',
288
+ service: 's',
289
+ method: 'm',
290
+ }),
291
+ ).rejects.toThrow('500')
292
+ })
293
+ })