@empyria/restate 0.4.6 → 0.4.8

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/lib/Admin.js CHANGED
@@ -157,7 +157,8 @@ export async function queryRestate({ restateAdminURL, sql }) {
157
157
  }
158
158
 
159
159
  /**
160
- * Force-deletes a deployment from Restate, draining any in-flight invocations.
160
+ * Force-deletes a deployment from Restate, draining any in-flight invocations. A deployment Restate no
161
+ * longer knows (404) counts as deleted: the goal is that it is gone, so the call is safe to repeat.
161
162
  * @param {string} restateAdminURL
162
163
  * @param {string} deploymentId
163
164
  * @returns {Promise<void>}
@@ -166,7 +167,7 @@ export async function deleteDeployment(restateAdminURL, deploymentId) {
166
167
  const response = await fetch(`${restateAdminURL}/deployments/${deploymentId}?force=true`, {
167
168
  method: 'DELETE',
168
169
  })
169
- if (!response.ok) {
170
+ if (!response.ok && response.status !== 404) {
170
171
  throw new restate.TerminalError(
171
172
  `Failed to delete deployment: ${response.status} ${response.statusText}`,
172
173
  { errorCode: ERROR_INFRASTRUCTURE },
@@ -408,6 +409,44 @@ export async function pollInvocation({ restateURL, invocationId, intervalMs = 25
408
409
  }
409
410
  }
410
411
 
412
+ /**
413
+ * Looks once, without waiting, at the result of an invocation (the `/output` endpoint).
414
+ *
415
+ * - `running`: no result yet
416
+ * - `done`: it finished; `output` is its result
417
+ * - `failed`: it ended in an error; `code` and `message` come from Restate
418
+ * - `unknown`: Restate has no such invocation (never existed, or its result was kept long enough and is gone)
419
+ * @param {{restateURL: string, invocationId: string}} params
420
+ * @returns {Promise<{state: 'running'}|{state: 'done', output: unknown}|{state: 'failed', code: number, message: string}|{state: 'unknown'}>}
421
+ */
422
+ export async function getInvocationOutput({ restateURL, invocationId }) {
423
+ const response = await fetch(`${restateURL}/restate/invocation/${invocationId}/output`)
424
+ if (response.status === 470) return { state: 'running' }
425
+
426
+ const text = await response.text()
427
+ let body
428
+ try {
429
+ body = text ? JSON.parse(text) : undefined
430
+ } catch {
431
+ body = undefined
432
+ }
433
+
434
+ if (response.ok) return { state: 'done', output: body }
435
+ // An error of the invocation itself carries `source: 'invocation'`; anything else is the ingress saying it
436
+ // does not know the invocation, or could not look.
437
+ if (body?.source === 'invocation') {
438
+ return {
439
+ state: 'failed',
440
+ code: Number(body.code ?? response.status),
441
+ message: String(body.message ?? response.statusText),
442
+ }
443
+ }
444
+ if (response.status === 400 || response.status === 404) return { state: 'unknown' }
445
+ throw new Error(
446
+ `Unexpected status ${response.status} reading invocation '${invocationId}': ${response.statusText}`,
447
+ )
448
+ }
449
+
411
450
  /**
412
451
  * Request headers for an ingress call, adding Restate's `idempotency-key` when given.
413
452
  * @param {string} [idempotencyKey]
@@ -790,6 +829,7 @@ export const sendMessageAsynSafe = async ({ restateURL, callerParams, timeout })
790
829
  * @property {() => (message: WorkflowMessage) => Promise<InvocationSubmission>} submitWorkflowDiscovery
791
830
  * @property {(params: {invocationId: string}) => Promise<*>} waitForInvocation
792
831
  * @property {(params: {invocationId: string, intervalMs?: number}) => Promise<*>} pollInvocation
832
+ * @property {(params: {invocationId: string}) => Promise<Awaited<ReturnType<typeof getInvocationOutput>>>} getInvocationOutput
793
833
  * @property {(params: Omit<ServiceMessage, 'restateURL'>) => Promise<*>} sendMessage
794
834
  * @property {(params: Omit<ServiceMessage, 'restateURL'>) => Promise<InvocationSubmission>} sendMessageAsync
795
835
  * @property {() => (message: ServiceMessage) => Promise<*>} sendMessageWithDiscovery
@@ -832,6 +872,8 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
832
872
  pollInvocation: ({ invocationId, intervalMs }) =>
833
873
  pollInvocation({ restateURL, invocationId, intervalMs }),
834
874
 
875
+ getInvocationOutput: ({ invocationId }) => getInvocationOutput({ restateURL, invocationId }),
876
+
835
877
  sendMessage: ({ name, message, key, payload, idempotencyKey }) =>
836
878
  sendMessage({ restateURL, name, message, key, payload, idempotencyKey }),
837
879
  sendMessageAsync: ({ name, message, key, payload, idempotencyKey }) =>
package/lib/Cron.js CHANGED
@@ -10,6 +10,18 @@ export const setJobStateName = (jobStateName) => {
10
10
  return JOB_STATE_NAME
11
11
  }
12
12
 
13
+ /**
14
+ * A retry policy that never gives up: it backs off to one attempt a minute and keeps trying. Restate's default
15
+ * stops after a few attempts and *pauses* the invocation, and a paused invocation of a virtual object holds the
16
+ * object's lock, so every later call queues behind it for ever. A schedule that was due while the services were
17
+ * down (a restart, a crash, a night off) must simply run when they are back, so jobs use this.
18
+ */
19
+ export const RETRY_FOREVER = {
20
+ initialInterval: { seconds: 1 },
21
+ maxInterval: { minutes: 1 },
22
+ exponentiationFactor: 2,
23
+ }
24
+
13
25
  export const cronJobInitiator = restate.service({
14
26
  name: 'CronJobInitiator',
15
27
  handlers: {
@@ -25,6 +37,7 @@ export const cronJobInitiator = restate.service({
25
37
 
26
38
  export const cronJob = restate.object({
27
39
  name: 'CronJob',
40
+ options: { retryPolicy: RETRY_FOREVER },
28
41
  handlers: {
29
42
  initiate: async (ctx, request) => {
30
43
  if (await ctx.get(JOB_STATE_NAME)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Restate.dev helpers for the Empyria nanoservice framework",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -14,6 +14,7 @@ import {
14
14
  submitWorkflowDiscovery,
15
15
  waitForInvocation,
16
16
  pollInvocation,
17
+ getInvocationOutput,
17
18
  sendMessage,
18
19
  sendMessageAsync,
19
20
  sendMessageWithDiscovery,
@@ -229,6 +230,11 @@ describe('deleteDeployment', () => {
229
230
  await expect(deleteDeployment('http://admin', 'dep-1')).resolves.toBeUndefined()
230
231
  })
231
232
 
233
+ test('treats an unknown deployment (404) as already deleted', async () => {
234
+ globalThis.fetch = mock(async () => jsonResponse(null, false, 404))
235
+ await expect(deleteDeployment('http://admin', 'dep-1')).resolves.toBeUndefined()
236
+ })
237
+
232
238
  test('throws on failure', async () => {
233
239
  globalThis.fetch = mock(async () => jsonResponse(null, false))
234
240
  await expect(deleteDeployment('http://admin', 'dep-1')).rejects.toThrow(
@@ -309,6 +315,40 @@ describe('submitWorkflowDiscovery', () => {
309
315
  })
310
316
  })
311
317
 
318
+ describe('getInvocationOutput', () => {
319
+ const look = (response) => {
320
+ globalThis.fetch = mock(async () => response)
321
+ return getInvocationOutput({ restateURL: 'http://ingress', invocationId: 'inv-1' })
322
+ }
323
+
324
+ test('done: returns the output', async () => {
325
+ expect(await look(jsonResponse({ a: 1 }))).toEqual({ state: 'done', output: { a: 1 } })
326
+ })
327
+
328
+ test('running: 470 means no result yet', async () => {
329
+ expect(await look(jsonResponse(null, false, 470))).toEqual({ state: 'running' })
330
+ })
331
+
332
+ test('failed: an error of the invocation itself', async () => {
333
+ const body = { code: 404, message: 'no such skill', source: 'invocation' }
334
+ expect(await look(jsonResponse(body, false, 404))).toEqual({
335
+ state: 'failed',
336
+ code: 404,
337
+ message: 'no such skill',
338
+ })
339
+ })
340
+
341
+ test('unknown: the ingress does not know the invocation', async () => {
342
+ expect(await look(jsonResponse({ message: 'x', source: 'ingress' }, false, 400))).toEqual({
343
+ state: 'unknown',
344
+ })
345
+ })
346
+
347
+ test('anything else is an error', async () => {
348
+ await expect(look(jsonResponse(null, false, 503))).rejects.toThrow('503')
349
+ })
350
+ })
351
+
312
352
  describe('waitForInvocation / pollInvocation', () => {
313
353
  test('waitForInvocation returns the attach result', async () => {
314
354
  globalThis.fetch = mock(async () => jsonResponse({ done: true }))
package/test/Cron.test.js CHANGED
@@ -291,3 +291,11 @@ describe('ensureCronJob', () => {
291
291
  ).rejects.toThrow('500')
292
292
  })
293
293
  })
294
+
295
+ describe('retry policy', () => {
296
+ test('a job never gives up: no attempt limit, so it is never paused', async () => {
297
+ const { RETRY_FOREVER } = await import('../lib/Cron.js')
298
+ expect(RETRY_FOREVER.maxAttempts).toBeUndefined()
299
+ expect(cronJob.options?.retryPolicy ?? cronJob.retryPolicy).toBeDefined()
300
+ })
301
+ })