@empyria/restate 0.4.16 → 0.4.18
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 +6 -0
- package/README.md +8 -4
- package/lib/Admin.js +107 -92
- package/package.json +1 -1
- package/test/Admin.test.js +31 -1
package/AGENTS.md
CHANGED
|
@@ -79,6 +79,12 @@ may observe an already-mocked value. See `test/Admin.test.js`.
|
|
|
79
79
|
same address registers again, but only `force: true` updates its handler list. Check both against a live server when
|
|
80
80
|
touching them.
|
|
81
81
|
|
|
82
|
+
`setupRestate` takes ONE complete `serviceURL` (scheme and explicit port included): it is registered as given and the
|
|
83
|
+
endpoint listens on its port. There is deliberately no `host` or `port` option — never assemble a URL from parts, the
|
|
84
|
+
scheme and any mapping are the caller's to state. `parseServiceURL` validates it (unit tested).
|
|
85
|
+
`createRestateAdmin().setupRestate` must forward every option to `setupRestate` — it once dropped
|
|
86
|
+
`keepDeploymentOnShutdown`/`forceRegistration`/`registrationCallbackFn`.
|
|
87
|
+
|
|
82
88
|
`setupRestate` (in `lib/Admin.js`) is not unit tested: it binds real HTTP/2 + health-check
|
|
83
89
|
ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
|
|
84
90
|
`process.exit()` — none of which are safe to exercise in-process in a test run.
|
package/README.md
CHANGED
|
@@ -122,9 +122,14 @@ endpoint, then create jobs with either:
|
|
|
122
122
|
Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
|
|
123
123
|
`payload` as its input, or no input when there is none.
|
|
124
124
|
|
|
125
|
-
###
|
|
125
|
+
### Serving and registering an endpoint
|
|
126
126
|
|
|
127
|
-
`setupRestate
|
|
127
|
+
`setupRestate({ restateAdminURL, serviceURL: 'https://agent-service.internal:9080', services })` takes one complete URL, the
|
|
128
|
+
address Restate reaches the endpoint at. It is registered as given and the endpoint listens on its port, so the scheme and
|
|
129
|
+
an explicit port are required (there is no separate host or port option); the health endpoint listens on the next port
|
|
130
|
+
unless `healthPort` is set.
|
|
131
|
+
|
|
132
|
+
It serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
|
|
128
133
|
pinned to their deployment, so deleting it strands them: a restarted process cannot finish them and they can only be
|
|
129
134
|
killed. For a process restarted at the same address, pass `keepDeploymentOnShutdown: true` and `forceRegistration: true`:
|
|
130
135
|
Restate keeps the deployment ID when the same address registers again (checked against Restate 1.7), running
|
|
@@ -132,8 +137,7 @@ invocations resume on the new process, and `force` updates the services and hand
|
|
|
132
137
|
re-registration keeps the old handler list). Only compatible changes are safe this way: an invocation of a removed or
|
|
133
138
|
renamed handler cannot resume.
|
|
134
139
|
|
|
135
|
-
|
|
136
|
-
registration (a supervisor that registers a process's address once and restarts the process), use
|
|
140
|
+
To serve without registering: when something else owns registration (a supervisor that registers a process's address once and restarts the process), use
|
|
137
141
|
`serveEndpoint({port, services})` instead: it only serves. Invocations already running on a deployment are pinned to its
|
|
138
142
|
address, so the deployment must survive the process stopping and come back at the same address.
|
|
139
143
|
|
package/lib/Admin.js
CHANGED
|
@@ -550,7 +550,7 @@ export function sendMessageAsyncWithDiscovery({ restateAdminURL }) {
|
|
|
550
550
|
}
|
|
551
551
|
|
|
552
552
|
/**
|
|
553
|
-
* @typedef {import('node:http2').Http2Server & {deploymentId: string, services: string[], forceClose: () => void}} RestateServer
|
|
553
|
+
* @typedef {import('node:http2').Http2Server & {deploymentId: string|undefined, services: string[]|undefined, forceClose: () => void}} RestateServer
|
|
554
554
|
*/
|
|
555
555
|
|
|
556
556
|
/**
|
|
@@ -592,8 +592,9 @@ export function defineObject(def) {
|
|
|
592
592
|
* that was removed or renamed cannot resume.
|
|
593
593
|
* @param {Object} params
|
|
594
594
|
* @param {string} params.restateAdminURL
|
|
595
|
-
* @param {string} params.
|
|
596
|
-
*
|
|
595
|
+
* @param {string} params.serviceURL Complete URL Restate uses to reach this endpoint, scheme and port included
|
|
596
|
+
* (e.g. `https://agent-service.internal:9080`). It is registered as given, and the endpoint listens on its port, so
|
|
597
|
+
* the port must be explicit. Scheme and port are the caller's to state, never guessed here.
|
|
597
598
|
* @param {number} [params.healthPort] - Defaults to `port + 1`.
|
|
598
599
|
* @param {Parameters<typeof restate.createEndpointHandler>[0]['services']} [params.services]
|
|
599
600
|
* @param {() => void} [params.shutdownCallbackFn]
|
|
@@ -605,15 +606,17 @@ export function defineObject(def) {
|
|
|
605
606
|
*/
|
|
606
607
|
export async function setupRestate({
|
|
607
608
|
restateAdminURL,
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
healthPort = port + 1,
|
|
609
|
+
serviceURL: serviceURLParam,
|
|
610
|
+
healthPort: healthPortParam,
|
|
611
611
|
services = [],
|
|
612
612
|
shutdownCallbackFn,
|
|
613
613
|
registrationCallbackFn,
|
|
614
614
|
keepDeploymentOnShutdown = false,
|
|
615
615
|
forceRegistration = false,
|
|
616
616
|
}) {
|
|
617
|
+
const { serviceURL, port } = parseServiceURL(serviceURLParam)
|
|
618
|
+
const healthPort = healthPortParam ?? port + 1
|
|
619
|
+
|
|
617
620
|
const handler = restate.createEndpointHandler({ services })
|
|
618
621
|
|
|
619
622
|
const server = http2.createServer(handler)
|
|
@@ -684,101 +687,121 @@ export async function setupRestate({
|
|
|
684
687
|
|
|
685
688
|
server.listen(port, () => console.log(`Server running on ${port}`))
|
|
686
689
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
690
|
+
/**
|
|
691
|
+
* @param {Object} params
|
|
692
|
+
* @param {string} params.restateAdminURL
|
|
693
|
+
* @param {string} params.serviceURL
|
|
694
|
+
* @param {number} [params.attempts=5]
|
|
695
|
+
* @param {number} [params.baseDelayMs=3000]
|
|
696
|
+
* @returns {Promise<RegisterDeploymentResponse>}
|
|
697
|
+
*/
|
|
698
|
+
async function registerDeploymentWithRetry({
|
|
699
|
+
restateAdminURL,
|
|
700
|
+
serviceURL,
|
|
701
|
+
attempts = 5,
|
|
702
|
+
baseDelayMs = 3_000,
|
|
703
|
+
}) {
|
|
704
|
+
let lastErr
|
|
705
|
+
|
|
706
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
707
|
+
try {
|
|
708
|
+
const result = await registerDeployment({
|
|
709
|
+
restateAdminURL,
|
|
710
|
+
serviceURL,
|
|
711
|
+
force: forceRegistration,
|
|
712
|
+
})
|
|
713
|
+
|
|
707
714
|
try {
|
|
708
|
-
|
|
709
|
-
restateAdminURL,
|
|
710
|
-
serviceURL,
|
|
711
|
-
force: forceRegistration,
|
|
712
|
-
})
|
|
713
|
-
|
|
714
|
-
try {
|
|
715
|
-
registrationCallbackFn?.(result.id)
|
|
716
|
-
} catch (err) {
|
|
717
|
-
console.error(
|
|
718
|
-
`Error in registration callback for deployment ${result.id}: ${
|
|
719
|
-
err instanceof Error ? err.message : String(err)
|
|
720
|
-
}`,
|
|
721
|
-
)
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
return result
|
|
715
|
+
registrationCallbackFn?.(result.id)
|
|
725
716
|
} catch (err) {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
if (attempt < attempts) {
|
|
734
|
-
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
|
|
735
|
-
console.log(
|
|
736
|
-
`Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
|
|
737
|
-
err instanceof Error ? err.message : String(err)
|
|
738
|
-
}`,
|
|
739
|
-
)
|
|
740
|
-
await sleep(delay)
|
|
741
|
-
}
|
|
717
|
+
console.error(
|
|
718
|
+
`Error in registration callback for deployment ${result.id}: ${
|
|
719
|
+
err instanceof Error ? err.message : String(err)
|
|
720
|
+
}`,
|
|
721
|
+
)
|
|
742
722
|
}
|
|
743
|
-
}
|
|
744
723
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}`,
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
const deployment = await registerDeploymentWithRetry({
|
|
752
|
-
restateAdminURL,
|
|
753
|
-
serviceURL,
|
|
754
|
-
}).catch(console.error)
|
|
724
|
+
return result
|
|
725
|
+
} catch (err) {
|
|
726
|
+
lastErr = err
|
|
755
727
|
|
|
756
|
-
|
|
728
|
+
// Deterministic — retrying won't help. Bail immediately.
|
|
729
|
+
if (err instanceof Error && err.message.includes('META0004')) {
|
|
730
|
+
throw err
|
|
731
|
+
}
|
|
757
732
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
733
|
+
if (attempt < attempts) {
|
|
734
|
+
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), DELAY_CAP)
|
|
735
|
+
console.log(
|
|
736
|
+
`Registration attempt ${attempt}/${attempts} failed, retrying in ${delay}ms: ${
|
|
737
|
+
err instanceof Error ? err.message : String(err)
|
|
738
|
+
}`,
|
|
739
|
+
)
|
|
740
|
+
await sleep(delay)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
throw new Error(
|
|
746
|
+
`Failed to register deployment after ${attempts} attempts: ${
|
|
747
|
+
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
748
|
+
}`,
|
|
749
|
+
)
|
|
765
750
|
}
|
|
751
|
+
const deployment = await registerDeploymentWithRetry({
|
|
752
|
+
restateAdminURL,
|
|
753
|
+
serviceURL,
|
|
754
|
+
}).catch(console.error)
|
|
755
|
+
|
|
756
|
+
isHealthy = !!deployment // Only healthy once registered
|
|
766
757
|
|
|
767
758
|
return Object.assign(server, {
|
|
768
|
-
deploymentId:
|
|
769
|
-
services:
|
|
759
|
+
deploymentId: deployment?.id,
|
|
760
|
+
services: deployment?.services?.map((s) => s.name),
|
|
770
761
|
forceClose,
|
|
771
762
|
})
|
|
772
763
|
}
|
|
773
764
|
|
|
774
765
|
/**
|
|
775
|
-
*
|
|
776
|
-
*
|
|
766
|
+
* Validates the URL `setupRestate` registers with Restate and returns it with the port the endpoint listens on.
|
|
767
|
+
* @param {string} serviceURL Complete http(s) URL including an explicit port
|
|
768
|
+
* @returns {{serviceURL: string, port: number}} `serviceURL` unchanged
|
|
769
|
+
* @throws {Error} If it is missing, not an absolute http(s) URL, or has no explicit port (the scheme's default would
|
|
770
|
+
* be a guess)
|
|
771
|
+
*/
|
|
772
|
+
export function parseServiceURL(serviceURL) {
|
|
773
|
+
if (!serviceURL) {
|
|
774
|
+
throw new Error(
|
|
775
|
+
'setupRestate needs `serviceURL`: the complete URL (scheme and port) Restate reaches this endpoint at',
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
let url
|
|
779
|
+
try {
|
|
780
|
+
url = new URL(serviceURL)
|
|
781
|
+
} catch {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`serviceURL must be a complete URL including http:// or https://, got '${serviceURL}'`,
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
787
|
+
throw new Error(`serviceURL must use http or https, got '${serviceURL}'`)
|
|
788
|
+
}
|
|
789
|
+
if (!url.port) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`serviceURL '${serviceURL}' has no explicit port: the endpoint listens on the port in the URL`,
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
return { serviceURL, port: Number(url.port) }
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Whether `setupRestate`'s shutdown removes the deployment: only one that registered (`deploymentId` is undefined when registration
|
|
799
|
+
* failed), and not when the caller keeps it for the next start.
|
|
777
800
|
* @param {{deploymentId: string|undefined, keepDeploymentOnShutdown?: boolean}} params
|
|
778
801
|
* @returns {boolean}
|
|
779
802
|
*/
|
|
780
803
|
export function shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown = false }) {
|
|
781
|
-
return Boolean(deploymentId) &&
|
|
804
|
+
return Boolean(deploymentId) && !keepDeploymentOnShutdown
|
|
782
805
|
}
|
|
783
806
|
|
|
784
807
|
/**
|
|
@@ -885,15 +908,7 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
|
|
|
885
908
|
listHandlers: ({ name }) => listHandlers({ restateAdminURL, name }),
|
|
886
909
|
|
|
887
910
|
// Management queries
|
|
888
|
-
setupRestate: (
|
|
889
|
-
setupRestate({
|
|
890
|
-
restateAdminURL,
|
|
891
|
-
host,
|
|
892
|
-
port,
|
|
893
|
-
healthPort: healthPort ?? port + 1,
|
|
894
|
-
services,
|
|
895
|
-
shutdownCallbackFn,
|
|
896
|
-
}),
|
|
911
|
+
setupRestate: (params) => setupRestate({ ...params, restateAdminURL }),
|
|
897
912
|
registerDeployment: ({ serviceURL }) => registerDeployment({ restateAdminURL, serviceURL }),
|
|
898
913
|
|
|
899
914
|
submitWorkflow: ({ name, key, payload }) =>
|
package/package.json
CHANGED
package/test/Admin.test.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
sendMessageAsyncWithDiscovery,
|
|
22
22
|
registerDeployment,
|
|
23
23
|
shouldDeleteOnShutdown,
|
|
24
|
+
parseServiceURL,
|
|
24
25
|
sendMessageAsynSafe,
|
|
25
26
|
defineWorkflow,
|
|
26
27
|
defineService,
|
|
@@ -542,6 +543,35 @@ describe('registerDeployment', () => {
|
|
|
542
543
|
})
|
|
543
544
|
})
|
|
544
545
|
|
|
546
|
+
describe('parseServiceURL', () => {
|
|
547
|
+
test('returns the URL unchanged with the port it carries', () => {
|
|
548
|
+
expect(parseServiceURL('http://agent-service:9080')).toEqual({
|
|
549
|
+
serviceURL: 'http://agent-service:9080',
|
|
550
|
+
port: 9080,
|
|
551
|
+
})
|
|
552
|
+
expect(parseServiceURL('https://svc.example.com:9443/base')).toEqual({
|
|
553
|
+
serviceURL: 'https://svc.example.com:9443/base',
|
|
554
|
+
port: 9443,
|
|
555
|
+
})
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
test('requires a serviceURL', () => {
|
|
559
|
+
expect(() => parseServiceURL(undefined)).toThrow(/needs `serviceURL`/)
|
|
560
|
+
expect(() => parseServiceURL('')).toThrow(/needs `serviceURL`/)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('rejects a URL without a scheme or with a non-http scheme', () => {
|
|
564
|
+
expect(() => parseServiceURL('agent-service:9080')).toThrow(/http/)
|
|
565
|
+
expect(() => parseServiceURL('not a url')).toThrow(/complete URL/)
|
|
566
|
+
expect(() => parseServiceURL('ftp://svc:21')).toThrow(/http or https/)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
test('refuses to guess the scheme default when the URL has no explicit port', () => {
|
|
570
|
+
expect(() => parseServiceURL('https://svc.example.com')).toThrow(/no explicit port/)
|
|
571
|
+
expect(() => parseServiceURL('http://svc:80')).toThrow(/no explicit port/)
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
|
|
545
575
|
describe('shouldDeleteOnShutdown', () => {
|
|
546
576
|
test('deletes a registered deployment by default', () => {
|
|
547
577
|
expect(shouldDeleteOnShutdown({ deploymentId: 'dep-1' })).toBe(true)
|
|
@@ -554,7 +584,7 @@ describe('shouldDeleteOnShutdown', () => {
|
|
|
554
584
|
})
|
|
555
585
|
|
|
556
586
|
test('never deletes without a real deployment', () => {
|
|
557
|
-
for (const deploymentId of [undefined, ''
|
|
587
|
+
for (const deploymentId of [undefined, '']) {
|
|
558
588
|
expect(shouldDeleteOnShutdown({ deploymentId })).toBe(false)
|
|
559
589
|
}
|
|
560
590
|
})
|