@empyria/restate 0.4.7 → 0.4.9

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/README.md CHANGED
@@ -50,6 +50,7 @@ import { checkServiceHandler } from '@empyria/restate/lib/Admin.js'
50
50
  | [lib/Cron.js](./lib/Cron.js) | `cronJobInitiator`/`cronJob` — Restate services implementing cron-driven, replay-safe recurring job scheduling on top of `croner`. |
51
51
  | [lib/Pubsub.js](./lib/Pubsub.js) | `definePubsub` (register a pubsub Virtual Object on your endpoint), `pubsubPublisher` (publish into it from inside a handler, in-process), `pubsubClient` (publish/pull/subscribe to it from anywhere else, over the network) — thin wrappers around `@restatedev/pubsub`/`@restatedev/pubsub-client`. |
52
52
  | [lib/Validation.js](./lib/Validation.js) | `withValidation` — wraps a workflow handler with input/output JSON schema validation via `@empyria/common`. |
53
+ | [lib/Endpoint.js](./lib/Endpoint.js) | `serveEndpoint` — serves services/workflows over HTTP/2 without registering itself or deleting its deployment on exit (for a supervisor that registers the address). |
53
54
 
54
55
  ## Agents: `@empyria/restate/agent`
55
56
 
@@ -121,6 +122,13 @@ endpoint, then create jobs with either:
121
122
  Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
122
123
  `payload` as its input, or no input when there is none.
123
124
 
125
+ ### An endpoint that does not register itself
126
+
127
+ `setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. When something else owns
128
+ registration (a supervisor that registers a process's address once and restarts the process), use
129
+ `serveEndpoint({port, services})` instead: it only serves. Invocations already running on a deployment are pinned to its
130
+ address, so the deployment must survive the process stopping and come back at the same address.
131
+
124
132
  ### Shaping an agent
125
133
 
126
134
  `defineAgent` options beyond the basics:
package/index.js CHANGED
@@ -7,3 +7,5 @@ export * from './lib/Admin.js'
7
7
  export * from './lib/Caller.js'
8
8
 
9
9
  export * from './lib/Pubsub.js'
10
+
11
+ export * from './lib/Endpoint.js'
package/lib/Admin.js CHANGED
@@ -409,6 +409,44 @@ export async function pollInvocation({ restateURL, invocationId, intervalMs = 25
409
409
  }
410
410
  }
411
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
+
412
450
  /**
413
451
  * Request headers for an ingress call, adding Restate's `idempotency-key` when given.
414
452
  * @param {string} [idempotencyKey]
@@ -791,6 +829,7 @@ export const sendMessageAsynSafe = async ({ restateURL, callerParams, timeout })
791
829
  * @property {() => (message: WorkflowMessage) => Promise<InvocationSubmission>} submitWorkflowDiscovery
792
830
  * @property {(params: {invocationId: string}) => Promise<*>} waitForInvocation
793
831
  * @property {(params: {invocationId: string, intervalMs?: number}) => Promise<*>} pollInvocation
832
+ * @property {(params: {invocationId: string}) => Promise<Awaited<ReturnType<typeof getInvocationOutput>>>} getInvocationOutput
794
833
  * @property {(params: Omit<ServiceMessage, 'restateURL'>) => Promise<*>} sendMessage
795
834
  * @property {(params: Omit<ServiceMessage, 'restateURL'>) => Promise<InvocationSubmission>} sendMessageAsync
796
835
  * @property {() => (message: ServiceMessage) => Promise<*>} sendMessageWithDiscovery
@@ -833,6 +872,8 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
833
872
  pollInvocation: ({ invocationId, intervalMs }) =>
834
873
  pollInvocation({ restateURL, invocationId, intervalMs }),
835
874
 
875
+ getInvocationOutput: ({ invocationId }) => getInvocationOutput({ restateURL, invocationId }),
876
+
836
877
  sendMessage: ({ name, message, key, payload, idempotencyKey }) =>
837
878
  sendMessage({ restateURL, name, message, key, payload, idempotencyKey }),
838
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)) {
@@ -0,0 +1,28 @@
1
+ import http2 from 'node:http2'
2
+ import * as restate from '@restatedev/restate-sdk'
3
+
4
+ /**
5
+ * An endpoint that only serves: the given services/workflows over HTTP/2 on `port`, and nothing else.
6
+ * Unlike `setupRestate` it does not register with Restate and does not delete its deployment when it stops. Use it
7
+ * when something else owns registration, for example a supervisor that registers the address once and restarts the
8
+ * process: workflows already running on a deployment are pinned to its address and must be able to wait for it to
9
+ * come back. It stops accepting connections on SIGINT/SIGTERM.
10
+ *
11
+ * @param {{port: number, services: Parameters<typeof restate.createEndpointHandler>[0]['services']}} params
12
+ * @returns {Promise<import('node:http2').Http2Server>} resolves once it is listening
13
+ */
14
+ export async function serveEndpoint({ port, services }) {
15
+ const server = http2.createServer(restate.createEndpointHandler({ services }))
16
+ await new Promise((resolve, reject) => {
17
+ server.once('error', reject)
18
+ server.listen(port, resolve)
19
+ })
20
+ // Open sessions from Restate would keep close() waiting; in-flight calls are retried by Restate anyway.
21
+ const stop = () => {
22
+ server.close()
23
+ setTimeout(() => process.exit(0), 1000)
24
+ }
25
+ process.once('SIGINT', stop)
26
+ process.once('SIGTERM', stop)
27
+ return server
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/restate",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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,
@@ -314,6 +315,40 @@ describe('submitWorkflowDiscovery', () => {
314
315
  })
315
316
  })
316
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
+
317
352
  describe('waitForInvocation / pollInvocation', () => {
318
353
  test('waitForInvocation returns the attach result', async () => {
319
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
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import http2 from 'node:http2'
3
+ import * as restate from '@restatedev/restate-sdk'
4
+ import { serveEndpoint } from '../lib/Endpoint.js'
5
+
6
+ describe('serveEndpoint', () => {
7
+ test('listens over HTTP/2 and answers Restate discovery, without registering anywhere', async () => {
8
+ const hello = restate.service({ name: 'Hello', handlers: { hi: async () => 'hi' } })
9
+ const server = await serveEndpoint({ port: 0, services: [hello] })
10
+ const { port } = server.address()
11
+ const client = http2.connect(`http://127.0.0.1:${port}`)
12
+ const body = await new Promise((resolve, reject) => {
13
+ const req = client.request({
14
+ ':path': '/discover',
15
+ accept: 'application/vnd.restate.endpointmanifest.v3+json',
16
+ })
17
+ let text = ''
18
+ req.on('data', (chunk) => (text += chunk))
19
+ req.on('end', () => resolve(text))
20
+ req.on('error', reject)
21
+ req.end()
22
+ })
23
+ client.close()
24
+ server.close()
25
+ expect(JSON.parse(body).services.map((s) => s.name)).toEqual(['Hello'])
26
+ })
27
+
28
+ test('fails when the port is taken', async () => {
29
+ const first = await serveEndpoint({ port: 0, services: [] })
30
+ const { port } = first.address()
31
+ await expect(serveEndpoint({ port, services: [] })).rejects.toThrow()
32
+ first.close()
33
+ })
34
+ })