@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.
Files changed (31) hide show
  1. package/README.md +8 -0
  2. package/dist/src/_dedupCheck.js +1 -1
  3. package/dist/src/bin/discovery-local.d.ts +3 -0
  4. package/dist/src/bin/discovery-local.d.ts.map +1 -0
  5. package/dist/src/bin/discovery-local.js +58 -0
  6. package/dist/src/bin/discovery-local.js.map +1 -0
  7. package/dist/src/local/GGDiscoveryIPC.d.ts +8 -0
  8. package/dist/src/local/GGDiscoveryIPC.d.ts.map +1 -1
  9. package/dist/src/local/GGDiscoveryIPC.js +7 -0
  10. package/dist/src/local/GGDiscoveryIPC.js.map +1 -1
  11. package/dist/src/local/GGLocalDiscoveryClient.d.ts +3 -0
  12. package/dist/src/local/GGLocalDiscoveryClient.d.ts.map +1 -1
  13. package/dist/src/local/GGLocalDiscoveryClient.js +38 -2
  14. package/dist/src/local/GGLocalDiscoveryClient.js.map +1 -1
  15. package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts +3 -0
  16. package/dist/src/local/GGLocalDiscoveryResilientClient.d.ts.map +1 -1
  17. package/dist/src/local/GGLocalDiscoveryResilientClient.js +20 -4
  18. package/dist/src/local/GGLocalDiscoveryResilientClient.js.map +1 -1
  19. package/dist/src/local/GGLocalDiscoveryServer.d.ts +4 -1
  20. package/dist/src/local/GGLocalDiscoveryServer.d.ts.map +1 -1
  21. package/dist/src/local/GGLocalDiscoveryServer.js +14 -2
  22. package/dist/src/local/GGLocalDiscoveryServer.js.map +1 -1
  23. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  24. package/package.json +11 -7
  25. package/src/_dedupCheck.ts +1 -1
  26. package/src/bin/discovery-local.ts +58 -0
  27. package/src/local/GGDiscoveryIPC.ts +7 -0
  28. package/src/local/GGLocalDiscoveryClient.ts +36 -2
  29. package/src/local/GGLocalDiscoveryResilientClient.ts +19 -4
  30. package/src/local/GGLocalDiscoveryServer.ts +15 -3
  31. 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 (!this.client.isConnected()) {
81
- await this.client.connect();
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 = 9000) {
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 server = new IPCServer(this.port);
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
- constructor(server: IPCServer) {
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
+ });