@grest-ts/discovery-local 0.0.37 → 0.0.38
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 +8 -0
- package/dist/src/_dedupCheck.js +1 -1
- package/dist/src/bin/discovery-local.d.ts +3 -0
- package/dist/src/bin/discovery-local.d.ts.map +1 -0
- package/dist/src/bin/discovery-local.js +58 -0
- package/dist/src/bin/discovery-local.js.map +1 -0
- package/dist/src/local/GGDiscoveryIPC.d.ts +8 -0
- package/dist/src/local/GGDiscoveryIPC.d.ts.map +1 -1
- package/dist/src/local/GGDiscoveryIPC.js +7 -0
- package/dist/src/local/GGDiscoveryIPC.js.map +1 -1
- package/dist/src/local/GGLocalDiscoveryClient.d.ts +3 -0
- package/dist/src/local/GGLocalDiscoveryClient.d.ts.map +1 -1
- package/dist/src/local/GGLocalDiscoveryClient.js +38 -2
- package/dist/src/local/GGLocalDiscoveryClient.js.map +1 -1
- package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts +3 -0
- package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts.map +1 -1
- package/dist/src/local/GGLocalDiscoveryResilientClient.js +20 -4
- package/dist/src/local/GGLocalDiscoveryResilientClient.js.map +1 -1
- package/dist/src/local/GGLocalDiscoveryServer.d.ts +4 -1
- package/dist/src/local/GGLocalDiscoveryServer.d.ts.map +1 -1
- package/dist/src/local/GGLocalDiscoveryServer.js +14 -2
- package/dist/src/local/GGLocalDiscoveryServer.js.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +11 -7
- package/src/_dedupCheck.ts +1 -1
- package/src/bin/discovery-local.ts +58 -0
- package/src/local/GGDiscoveryIPC.ts +7 -0
- package/src/local/GGLocalDiscoveryClient.ts +36 -2
- package/src/local/GGLocalDiscoveryResilientClient.ts +19 -4
- package/src/local/GGLocalDiscoveryServer.ts +15 -3
- package/src/local/yield.test.ts +70 -0
|
@@ -8,10 +8,17 @@ export interface DiscoverApiResult {
|
|
|
8
8
|
error?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export enum DiscoveryServerKind {
|
|
12
|
+
Bin = "bin",
|
|
13
|
+
Embedded = "embedded",
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
export const GGDiscoveryIPC = {
|
|
12
17
|
discoveryServer: {
|
|
13
18
|
register: IPCServer.defineRequest<GGServiceDiscoveryEntry[], void>("discovery/register"),
|
|
14
19
|
unregister: IPCServer.defineRequest<GGServiceDiscoveryEntry[], void>("discovery/unregister"),
|
|
15
20
|
discoverApi: IPCServer.defineRequest<string, DiscoverApiResult>("discovery/discoverApi"),
|
|
21
|
+
getServerInfo: IPCServer.defineRequest<undefined, {kind: DiscoveryServerKind}>("discovery/getServerInfo"),
|
|
22
|
+
requestYield: IPCServer.defineRequest<undefined, void>("discovery/requestYield"),
|
|
16
23
|
}
|
|
17
24
|
}
|
|
@@ -11,6 +11,18 @@ import {SERVER_ERROR} from "@grest-ts/schema";
|
|
|
11
11
|
*/
|
|
12
12
|
export const GG_LOCAL_ROUTER_PORT = "GG_LOCAL_ROUTER_PORT"
|
|
13
13
|
|
|
14
|
+
/** Default port for the local discovery router. Centralised so a future
|
|
15
|
+
* change (env-var override, per-environment isolation) has one hook. */
|
|
16
|
+
export function getLocalDiscoveryPort(): number {
|
|
17
|
+
const v = process.env[GG_LOCAL_ROUTER_PORT];
|
|
18
|
+
if (v === undefined) return 9000;
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
21
|
+
throw new Error(`Invalid ${GG_LOCAL_ROUTER_PORT}=${v}, expected positive integer`);
|
|
22
|
+
}
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export class GGLocalDiscoveryClient extends GGDiscoveryClient {
|
|
15
27
|
|
|
16
28
|
public override readonly isLocal = true;
|
|
@@ -77,12 +89,34 @@ export class GGLocalDiscoveryClient extends GGDiscoveryClient {
|
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
protected async ensureConnected(): Promise<void> {
|
|
80
|
-
if (
|
|
81
|
-
|
|
92
|
+
if (this.client.isConnected()) return;
|
|
93
|
+
// Retry with backoff: when the router runs as its own process
|
|
94
|
+
// (e.g. launched via the `discovery-local` bin), a runtime can
|
|
95
|
+
// come up before the router has bound its port. Tolerate that
|
|
96
|
+
// instead of failing the runtime's startup outright.
|
|
97
|
+
const maxRetries = 20;
|
|
98
|
+
for (let attempt = 0; ; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
await this.client.connect();
|
|
101
|
+
return;
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
if (attempt >= maxRetries || !isConnectionError(err)) throw err;
|
|
104
|
+
GGLog.debug(this, "Waiting for discovery router...");
|
|
105
|
+
await new Promise(r => setTimeout(r, Math.min(500 * Math.pow(1.5, attempt), 5000)));
|
|
106
|
+
}
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
/** True for the transient socket errors seen while a router process is
|
|
112
|
+
* still coming up — worth retrying, unlike a real protocol failure. */
|
|
113
|
+
function isConnectionError(err: any): boolean {
|
|
114
|
+
const codes = ["ECONNREFUSED", "ETIMEDOUT", "ECONNRESET", "ENOENT"];
|
|
115
|
+
const has = (code?: string) => code !== undefined && codes.includes(code);
|
|
116
|
+
return has(err?.code) || has(err?.cause?.code)
|
|
117
|
+
|| has(err?.originalError?.code) || has(err?.originalError?.cause?.code);
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
/**
|
|
87
121
|
* What consumers need to discover and connect to a service
|
|
88
122
|
*/
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import {GGLog} from "@grest-ts/logger";
|
|
2
2
|
import {IPCServer} from "@grest-ts/ipc";
|
|
3
3
|
import {GGLocalDiscoveryServer} from "./GGLocalDiscoveryServer";
|
|
4
|
-
import {GGLocalDiscoveryClient} from "./GGLocalDiscoveryClient";
|
|
4
|
+
import {GGLocalDiscoveryClient, getLocalDiscoveryPort} from "./GGLocalDiscoveryClient";
|
|
5
|
+
import {GGDiscoveryIPC, DiscoveryServerKind} from "./GGDiscoveryIPC";
|
|
5
6
|
|
|
6
7
|
export class GGLocalDiscoveryResilientClient extends GGLocalDiscoveryClient {
|
|
7
8
|
|
|
8
9
|
private discoveryServer?: GGLocalDiscoveryServer;
|
|
9
10
|
private isLeader = false;
|
|
10
11
|
private isShuttingDown = false;
|
|
12
|
+
/** Once any bin has held the port in this runtime's lifetime, never
|
|
13
|
+
* bid for it again. In-memory only. */
|
|
14
|
+
private seenBinBasedMaster = false;
|
|
11
15
|
|
|
12
|
-
constructor(port =
|
|
16
|
+
constructor(port = getLocalDiscoveryPort()) {
|
|
13
17
|
super(port);
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -32,12 +36,18 @@ export class GGLocalDiscoveryResilientClient extends GGLocalDiscoveryClient {
|
|
|
32
36
|
|
|
33
37
|
private async becomeLeaderOrFollower(): Promise<void> {
|
|
34
38
|
if (this.isShuttingDown) return;
|
|
39
|
+
if (this.seenBinBasedMaster) return this.connectToLeader();
|
|
35
40
|
|
|
36
|
-
const
|
|
37
|
-
const router = new GGLocalDiscoveryServer(server);
|
|
41
|
+
const router = new GGLocalDiscoveryServer(new IPCServer(this.port));
|
|
38
42
|
if (await router.start()) {
|
|
39
43
|
this.discoveryServer = router;
|
|
40
44
|
this.isLeader = true;
|
|
45
|
+
router.onYield = async () => {
|
|
46
|
+
this.seenBinBasedMaster = true;
|
|
47
|
+
this.isLeader = false;
|
|
48
|
+
this.discoveryServer = undefined;
|
|
49
|
+
await this.connectToLeader();
|
|
50
|
+
};
|
|
41
51
|
GGLog.info(this, "This instance is LEADER");
|
|
42
52
|
} else {
|
|
43
53
|
this.isLeader = false;
|
|
@@ -52,6 +62,11 @@ export class GGLocalDiscoveryResilientClient extends GGLocalDiscoveryClient {
|
|
|
52
62
|
try {
|
|
53
63
|
await this.client.connect();
|
|
54
64
|
|
|
65
|
+
if (!this.seenBinBasedMaster) {
|
|
66
|
+
const info = await this.client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.getServerInfo, undefined);
|
|
67
|
+
if (info.kind === DiscoveryServerKind.Bin) this.seenBinBasedMaster = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
this.client.onClose(async () => {
|
|
56
71
|
if (this.isShuttingDown) return;
|
|
57
72
|
GGLog.warn(this, "Leader died");
|
|
@@ -5,7 +5,7 @@ import {FirstStrategy} from "./routing/strategies/FirstStrategy";
|
|
|
5
5
|
import {LastStrategy} from "./routing/strategies/LastStrategy";
|
|
6
6
|
import {RandomStrategy} from "./routing/strategies/RandomStrategy";
|
|
7
7
|
import {RoundRobinStrategy} from "./routing/strategies/RoundRobinStrategy";
|
|
8
|
-
import {GGDiscoveryIPC} from "./GGDiscoveryIPC";
|
|
8
|
+
import {GGDiscoveryIPC, DiscoveryServerKind} from "./GGDiscoveryIPC";
|
|
9
9
|
import {GGServiceDiscoveryEntry} from "./GGLocalDiscoveryClient";
|
|
10
10
|
|
|
11
11
|
export const ROUTING_STRATEGIES = {
|
|
@@ -32,8 +32,9 @@ export class GGLocalDiscoveryServer {
|
|
|
32
32
|
private readonly server: IPCServer;
|
|
33
33
|
private readonly routes: Map<string, RegisteredEntry[]> = new Map();
|
|
34
34
|
private readonly routingStrategies: Map<string, RoutingStrategy> = new Map();
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
public onYield?: () => Promise<void>;
|
|
36
|
+
|
|
37
|
+
constructor(server: IPCServer, public readonly kind: DiscoveryServerKind = DiscoveryServerKind.Embedded) {
|
|
37
38
|
this.server = server;
|
|
38
39
|
|
|
39
40
|
// Socket handlers for framework communication
|
|
@@ -57,6 +58,17 @@ export class GGLocalDiscoveryServer {
|
|
|
57
58
|
return {success: false, error: "Service '" + apiName + "' is not registered! Did you forget to start it?"};
|
|
58
59
|
});
|
|
59
60
|
|
|
61
|
+
this.server.onFrameworkMessage(GGDiscoveryIPC.discoveryServer.getServerInfo, async () => ({kind: this.kind}));
|
|
62
|
+
|
|
63
|
+
// Defer release so the ack flushes before we close the socket.
|
|
64
|
+
// onYield owners (e.g. resilient client) own teardown themselves.
|
|
65
|
+
this.server.onFrameworkMessage(GGDiscoveryIPC.discoveryServer.requestYield, async () => {
|
|
66
|
+
setTimeout(async () => {
|
|
67
|
+
await this.teardown();
|
|
68
|
+
await this.onYield?.();
|
|
69
|
+
}, 10);
|
|
70
|
+
});
|
|
71
|
+
|
|
60
72
|
this.server.setRouteProxyResolver((path) => {
|
|
61
73
|
return this.matchRoute(path)?.baseUrl || undefined;
|
|
62
74
|
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {IPCServer, IPCClient} from "@grest-ts/ipc";
|
|
2
|
+
import {GGLocatorScope} from "@grest-ts/locator";
|
|
3
|
+
import {GGLocalDiscoveryServer} from "./GGLocalDiscoveryServer";
|
|
4
|
+
import {GGLocalDiscoveryResilientClient} from "./GGLocalDiscoveryResilientClient";
|
|
5
|
+
import {GGDiscoveryIPC, DiscoveryServerKind} from "./GGDiscoveryIPC";
|
|
6
|
+
|
|
7
|
+
async function startServer(kind: DiscoveryServerKind): Promise<{server: GGLocalDiscoveryServer, port: number}> {
|
|
8
|
+
const ipc = new IPCServer(0);
|
|
9
|
+
const server = new GGLocalDiscoveryServer(ipc, kind);
|
|
10
|
+
if (!(await server.start())) throw new Error("bind failed");
|
|
11
|
+
return {server, port: ipc.getPort()};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function isPortFree(port: number): Promise<boolean> {
|
|
15
|
+
const probe = new IPCServer(port);
|
|
16
|
+
const ok = await probe.start();
|
|
17
|
+
if (ok) await probe.teardown();
|
|
18
|
+
return ok;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("yield protocol", () => {
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
new GGLocatorScope("YieldProtocolTest").enter();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("getServerInfo returns kind; requestYield without onYield tears down", async () => {
|
|
28
|
+
const {server, port} = await startServer(DiscoveryServerKind.Bin);
|
|
29
|
+
const client = new IPCClient(port);
|
|
30
|
+
await client.connect();
|
|
31
|
+
const info = await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.getServerInfo, undefined);
|
|
32
|
+
expect(info.kind).toBe(DiscoveryServerKind.Bin);
|
|
33
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.requestYield, undefined);
|
|
34
|
+
client.disconnect();
|
|
35
|
+
await new Promise(r => setTimeout(r, 100));
|
|
36
|
+
expect(await isPortFree(port)).toBe(true);
|
|
37
|
+
await server.teardown().catch((): undefined => undefined);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("requestYield invokes onYield after teardown", async () => {
|
|
41
|
+
const {server, port} = await startServer(DiscoveryServerKind.Embedded);
|
|
42
|
+
let firedAfterTeardown = false;
|
|
43
|
+
server.onYield = async () => { firedAfterTeardown = await isPortFree(port); };
|
|
44
|
+
const client = new IPCClient(port);
|
|
45
|
+
await client.connect();
|
|
46
|
+
await client.sendFrameworkRequest(GGDiscoveryIPC.discoveryServer.requestYield, undefined);
|
|
47
|
+
client.disconnect();
|
|
48
|
+
await new Promise(r => setTimeout(r, 100));
|
|
49
|
+
expect(firedAfterTeardown).toBe(true);
|
|
50
|
+
expect(await isPortFree(port)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("resilient client that connected to a bin never re-bids after the bin dies", async () => {
|
|
54
|
+
const {server: bin, port} = await startServer(DiscoveryServerKind.Bin);
|
|
55
|
+
|
|
56
|
+
// Sub-scope with a no-op lifecycle owner so the client constructor
|
|
57
|
+
// (which does setWithLifecycle on GG_DISCOVERY) doesn't throw.
|
|
58
|
+
const subscope = new GGLocatorScope("ResilientClientTest");
|
|
59
|
+
subscope.setLifecycleOwner(() => undefined);
|
|
60
|
+
subscope.enter();
|
|
61
|
+
const resilient = new GGLocalDiscoveryResilientClient(port);
|
|
62
|
+
await resilient.register();
|
|
63
|
+
|
|
64
|
+
await bin.teardown();
|
|
65
|
+
await new Promise(r => setTimeout(r, 200));
|
|
66
|
+
|
|
67
|
+
expect(await isPortFree(port)).toBe(true);
|
|
68
|
+
await resilient.unregister();
|
|
69
|
+
});
|
|
70
|
+
});
|