@toa.io/extensions.introspection 1.0.0-alpha.262

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 (150) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/components/introspection.edges/manifest.toa.yaml +48 -0
  3. package/components/introspection.edges/operations/lib/types.d.ts +28 -0
  4. package/components/introspection.edges/operations/lib/types.js +3 -0
  5. package/components/introspection.edges/operations/lib/types.js.map +1 -0
  6. package/components/introspection.edges/operations/merge.d.ts +9 -0
  7. package/components/introspection.edges/operations/merge.js +25 -0
  8. package/components/introspection.edges/operations/merge.js.map +1 -0
  9. package/components/introspection.edges/source/lib/types.ts +33 -0
  10. package/components/introspection.edges/source/merge.ts +26 -0
  11. package/components/introspection.edges/tsconfig.json +9 -0
  12. package/components/introspection.nodes/manifest.toa.yaml +49 -0
  13. package/components/introspection.nodes/operations/lib/types.d.ts +16 -0
  14. package/components/introspection.nodes/operations/lib/types.js +3 -0
  15. package/components/introspection.nodes/operations/lib/types.js.map +1 -0
  16. package/components/introspection.nodes/operations/merge.d.ts +9 -0
  17. package/components/introspection.nodes/operations/merge.js +21 -0
  18. package/components/introspection.nodes/operations/merge.js.map +1 -0
  19. package/components/introspection.nodes/source/lib/types.ts +18 -0
  20. package/components/introspection.nodes/source/merge.ts +21 -0
  21. package/components/introspection.nodes/tsconfig.json +9 -0
  22. package/cucumber.js +8 -0
  23. package/features/calls.feature +123 -0
  24. package/features/nodes.feature +70 -0
  25. package/features/samples.feature +75 -0
  26. package/features/shutdown.feature +37 -0
  27. package/features/site/_app/immutable/asset.js +1 -0
  28. package/features/site/index.html +6 -0
  29. package/features/steps/Map.ts +151 -0
  30. package/features/steps/UI.ts +88 -0
  31. package/features/steps/components/probe.discreet/manifest.toa.yaml +12 -0
  32. package/features/steps/components/probe.discreet/operations/ping.js +7 -0
  33. package/features/steps/components/probe.quiet/manifest.toa.yaml +11 -0
  34. package/features/steps/components/probe.quiet/operations/ping.js +7 -0
  35. package/features/steps/components/probe.source/manifest.toa.yaml +27 -0
  36. package/features/steps/components/probe.source/operations/crash.js +7 -0
  37. package/features/steps/components/probe.source/operations/fail.js +12 -0
  38. package/features/steps/components/probe.source/operations/relay.js +7 -0
  39. package/features/steps/components/probe.target/manifest.toa.yaml +24 -0
  40. package/features/steps/components/probe.target/operations/compute.js +7 -0
  41. package/features/steps/components/probe.target/operations/count.js +9 -0
  42. package/features/steps/config.ts +20 -0
  43. package/features/steps/tsconfig.json +9 -0
  44. package/features/ui.feature +85 -0
  45. package/package.json +31 -0
  46. package/readme.md +141 -0
  47. package/schemas/annotation.cos.yaml +16 -0
  48. package/schemas/declaration.cos.yaml +2 -0
  49. package/source/Composition.ts +61 -0
  50. package/source/Explorer.ts +16 -0
  51. package/source/Factory.ts +119 -0
  52. package/source/Reporter.ts +210 -0
  53. package/source/Tenant.ts +47 -0
  54. package/source/UI.ts +197 -0
  55. package/source/annotation.ts +83 -0
  56. package/source/const.ts +32 -0
  57. package/source/describe.ts +68 -0
  58. package/source/extension.ts +45 -0
  59. package/source/index.ts +7 -0
  60. package/source/keys.ts +0 -0
  61. package/source/manifest.ts +19 -0
  62. package/source/model.ts +82 -0
  63. package/source/sample.ts +49 -0
  64. package/source/schemas.ts +10 -0
  65. package/transpiled/Composition.d.ts +21 -0
  66. package/transpiled/Composition.js +48 -0
  67. package/transpiled/Composition.js.map +1 -0
  68. package/transpiled/Explorer.d.ts +9 -0
  69. package/transpiled/Explorer.js +19 -0
  70. package/transpiled/Explorer.js.map +1 -0
  71. package/transpiled/Factory.d.ts +22 -0
  72. package/transpiled/Factory.js +93 -0
  73. package/transpiled/Factory.js.map +1 -0
  74. package/transpiled/Reporter.d.ts +44 -0
  75. package/transpiled/Reporter.js +187 -0
  76. package/transpiled/Reporter.js.map +1 -0
  77. package/transpiled/Tenant.d.ts +18 -0
  78. package/transpiled/Tenant.js +40 -0
  79. package/transpiled/Tenant.js.map +1 -0
  80. package/transpiled/UI.d.ts +25 -0
  81. package/transpiled/UI.js +186 -0
  82. package/transpiled/UI.js.map +1 -0
  83. package/transpiled/annotation.d.ts +40 -0
  84. package/transpiled/annotation.js +46 -0
  85. package/transpiled/annotation.js.map +1 -0
  86. package/transpiled/const.d.ts +23 -0
  87. package/transpiled/const.js +27 -0
  88. package/transpiled/const.js.map +1 -0
  89. package/transpiled/contracts.d.ts +51 -0
  90. package/transpiled/contracts.js +53 -0
  91. package/transpiled/contracts.js.map +1 -0
  92. package/transpiled/describe.d.ts +9 -0
  93. package/transpiled/describe.js +64 -0
  94. package/transpiled/describe.js.map +1 -0
  95. package/transpiled/extension.d.ts +11 -0
  96. package/transpiled/extension.js +64 -0
  97. package/transpiled/extension.js.map +1 -0
  98. package/transpiled/index.d.ts +6 -0
  99. package/transpiled/index.js +25 -0
  100. package/transpiled/index.js.map +1 -0
  101. package/transpiled/keys.d.ts +9 -0
  102. package/transpiled/keys.js +0 -0
  103. package/transpiled/keys.js.map +1 -0
  104. package/transpiled/manifest.d.ts +9 -0
  105. package/transpiled/manifest.js +43 -0
  106. package/transpiled/manifest.js.map +1 -0
  107. package/transpiled/model.d.ts +71 -0
  108. package/transpiled/model.js +3 -0
  109. package/transpiled/model.js.map +1 -0
  110. package/transpiled/sample.d.ts +7 -0
  111. package/transpiled/sample.js +44 -0
  112. package/transpiled/sample.js.map +1 -0
  113. package/transpiled/schemas.d.ts +4 -0
  114. package/transpiled/schemas.js +13 -0
  115. package/transpiled/schemas.js.map +1 -0
  116. package/tsconfig.json +12 -0
  117. package/ui/dist/_app/env.js +1 -0
  118. package/ui/dist/_app/immutable/assets/0.fg2cP6bO.css +2 -0
  119. package/ui/dist/_app/immutable/assets/2.BghF56l_.css +1 -0
  120. package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  121. package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  122. package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  123. package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  124. package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  125. package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  126. package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  127. package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  128. package/ui/dist/_app/immutable/chunks/CIvRtvVA.js +1 -0
  129. package/ui/dist/_app/immutable/chunks/D1jNriZ8.js +1 -0
  130. package/ui/dist/_app/immutable/chunks/DINmginf.js +1 -0
  131. package/ui/dist/_app/immutable/chunks/DKZCAkrF.js +1 -0
  132. package/ui/dist/_app/immutable/chunks/DLG2JvyF.js +6 -0
  133. package/ui/dist/_app/immutable/chunks/DSi-FjwI.js +2 -0
  134. package/ui/dist/_app/immutable/chunks/DdoZuyU1.js +1 -0
  135. package/ui/dist/_app/immutable/chunks/O9IjE_TH.js +1 -0
  136. package/ui/dist/_app/immutable/chunks/PUjOYRcs.js +3 -0
  137. package/ui/dist/_app/immutable/chunks/QKD4Mgck.js +5 -0
  138. package/ui/dist/_app/immutable/chunks/Ux-r8C9U.js +1 -0
  139. package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
  140. package/ui/dist/_app/immutable/entry/app.CTY34L35.js +2 -0
  141. package/ui/dist/_app/immutable/entry/start.Bq2Gn1Ax.js +1 -0
  142. package/ui/dist/_app/immutable/nodes/0.lOXtBRjQ.js +1 -0
  143. package/ui/dist/_app/immutable/nodes/1.r8ESZdgM.js +1 -0
  144. package/ui/dist/_app/immutable/nodes/2.DuVj0loz.js +8 -0
  145. package/ui/dist/_app/immutable/nodes/3.DhdkQ2Q1.js +1 -0
  146. package/ui/dist/_app/immutable/nodes/4.DUAEZdRr.js +1 -0
  147. package/ui/dist/_app/immutable/nodes/5.CIVn_xgq.js +1 -0
  148. package/ui/dist/_app/version.json +1 -0
  149. package/ui/dist/index.html +48 -0
  150. package/ui/dist/robots.txt +3 -0
package/readme.md ADDED
@@ -0,0 +1,141 @@
1
+ # Toa Introspection
2
+
3
+ Collects and shows how the parts of an application talk to each other.
4
+
5
+ Two things make that picture. Every component describes itself — what it can be asked to do, what it
6
+ announces, what it reacts to — and that much is true before a single request is served. Every call
7
+ between components is then recorded as it happens, along with what set it off. One says how the
8
+ application is put together, the other what it actually does.
9
+
10
+ It comes out as a page: a map of the components and the calls between them, and a list of what each
11
+ one declares. Nothing has to be declared to get it — the extension takes part in every composition.
12
+
13
+ ## Configuration
14
+
15
+ Both levels of the annotation are optional. Everything below shows the defaults.
16
+
17
+ ### Context
18
+
19
+ ```yaml
20
+ # context.toa.yaml
21
+
22
+ introspection:
23
+ samples: false # capture the actual payloads of calls
24
+ interval: 15 # how often a component reports, seconds
25
+ threshold: 256 # report earlier once this many edges are pending
26
+ ui: true # publish the UI
27
+ resources: # explorer pod limits
28
+ cpu: [100m, 500m]
29
+ memory: [128Mi, 256Mi]
30
+ ```
31
+
32
+ To turn introspection off completely — no explorer, no collection:
33
+
34
+ ```yaml
35
+ introspection: false
36
+ ```
37
+
38
+ Introspection exposes the map over HTTP and therefore requires the
39
+ [exposition](../exposition) extension. An application that does not use exposition has to turn
40
+ introspection off.
41
+
42
+ ### Component
43
+
44
+ ```yaml
45
+ # manifest.toa.yaml
46
+
47
+ introspection:
48
+ samples: false # never capture this component's payloads
49
+ ```
50
+
51
+ To keep a component off the map entirely:
52
+
53
+ ```yaml
54
+ introspection: false
55
+ ```
56
+
57
+ ### Samples
58
+
59
+ A sample is the actual input of a call, kept on the edge. It is production data, so it is off by
60
+ default, and both the context and the component have to allow it — either can veto. A component that
61
+ handles personal data should opt out permanently in its own manifest.
62
+
63
+ Payloads of the `identity` namespace are never captured, keys that look like secrets are masked, and
64
+ oversized payloads are dropped.
65
+
66
+ ## The UI
67
+
68
+ The UI is published at `/.introspection`, on the hosts the context declares:
69
+
70
+ ```yaml
71
+ # context.toa.yaml
72
+
73
+ ingress:
74
+ hosts:
75
+ - api.example.com
76
+ class: alb
77
+ annotations:
78
+ alb.ingress.kubernetes.io/group.name: example
79
+ ```
80
+
81
+ This section is what every service uses to reach the outside; without it the UI has nowhere to land
82
+ and `toa export` says so. To collect the map without publishing anything, set `ui: false`.
83
+
84
+ List the same hostnames Exposition serves — the page is static and reads the map from the API on its
85
+ own origin, so a host Exposition does not serve gives a page that cannot load anything.
86
+
87
+ Two services now share one host, which is a question for the ingress controller rather than for
88
+ Toa: ingress-nginx merges them, while AWS ALB needs `alb.ingress.kubernetes.io/group.name` on both.
89
+ The `annotations` above are applied to every service, which is the place to put it.
90
+
91
+ Reading the map still needs the `system:introspection` role. The page itself is served without
92
+ authentication — it is a page, and it displays nothing the API has not already granted.
93
+
94
+ ### Developing the UI
95
+
96
+ The page is a Svelte application under `ui`, built into `ui/dist` — a directory of files the
97
+ explorer serves, with no server rendering and nothing to run beside it. The mount path is baked
98
+ into the bundle, because the ingress forwards it rather than rewriting it, and the client router
99
+ matches against it.
100
+
101
+ The build is git-ignored and produced on `npm publish`, so from a checkout it is a one-time step,
102
+ like `npm run transpile`:
103
+
104
+ ```shell
105
+ $ npm run build:ui # in this package
106
+ ```
107
+
108
+ The explorer then answers on port `8002`, and `/` redirects to the mount path, so a local run
109
+ opens at <http://localhost:8002>. To work on the page itself:
110
+
111
+ ```shell
112
+ $ npm --prefix ui run dev # http://localhost:5173/.introspection
113
+ ```
114
+
115
+ Either way the API is the page's own origin — except on a local host, where the explorer and the
116
+ gateway are separate ports and the page asks `:8000` instead. Nothing configures this; see
117
+ `ui/src/config/index.ts`.
118
+
119
+ ## Reading the map
120
+
121
+ The map lives in two ordinary components, `introspection.nodes` and `introspection.edges`. Both
122
+ answer `enumerate` and `observe`, and both are exposed under the `system:introspection` role:
123
+
124
+ ```
125
+ GET /introspection/nodes/
126
+ GET /introspection/nodes/:id
127
+ GET /introspection/edges/
128
+ GET /introspection/edges/:id
129
+ ```
130
+
131
+ Records carry `_updated`, which is when the node or edge was last observed — that is how a component
132
+ that no longer exists is recognized.
133
+
134
+ ## Resources
135
+
136
+ The explorer runs as the `introspection-explorer` service on port `8002`. It needs a database: the
137
+ components use the context's `mongodb` annotation like any other.
138
+
139
+ A port belongs to one service only — `toa export` refuses two claims on the same one, because
140
+ `toa mono` and a local run put every service in one process. Taken so far: `8000` by the exposition
141
+ gateway, `8001` by the telemetry readiness probe, `8002` here.
@@ -0,0 +1,16 @@
1
+ # context.toa.yaml introspection annotation
2
+ samples: boolean
3
+ interval:
4
+ type: integer
5
+ minimum: 1
6
+ threshold:
7
+ type: integer
8
+ minimum: 1
9
+ ui: boolean
10
+ resources:
11
+ cpu: &constraint
12
+ type: array
13
+ items: { type: string }
14
+ minItems: 2
15
+ maxItems: 2
16
+ memory: *constraint
@@ -0,0 +1,2 @@
1
+ # manifest.toa.yaml introspection declaration
2
+ samples: boolean
@@ -0,0 +1,61 @@
1
+ import { readdirSync, type Dirent } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { Connector } from '@toa.io/core'
4
+ import { type Bootloader } from './Factory'
5
+ import type { Annotation } from './annotation'
6
+
7
+ /** Hosts the introspection components in the explorer process. */
8
+ export class Composition extends Connector {
9
+ private readonly boot: Bootloader
10
+
11
+ public constructor (boot: Bootloader) {
12
+ super()
13
+ this.boot = boot
14
+ }
15
+
16
+ protected override async open (): Promise<void> {
17
+ const paths = find()
18
+ const composition = await this.boot.composition(paths)
19
+
20
+ await composition.connect()
21
+
22
+ this.depends(composition)
23
+ }
24
+ }
25
+
26
+ export function find (): string[] {
27
+ return entries().map((entry) => resolve(ROOT, entry.name))
28
+ }
29
+
30
+ function entries (): Dirent[] {
31
+ const entries = readdirSync(ROOT, { withFileTypes: true })
32
+
33
+ return entries.filter((entry) => entry.isDirectory())
34
+ }
35
+
36
+ /**
37
+ * The extension is predefined, so an application that turns introspection off
38
+ * must not end up with the explorer components — nor with the exposition
39
+ * dependency they bring in.
40
+ */
41
+ export function components (annotation?: Annotation): Components {
42
+ if (annotation === false)
43
+ return { labels: [], paths: [] }
44
+
45
+ const labels: string[] = []
46
+ const paths: string[] = []
47
+
48
+ for (const entry of entries()) {
49
+ labels.push(entry.name.replace('.', '-'))
50
+ paths.push(resolve(ROOT, entry.name))
51
+ }
52
+
53
+ return { labels, paths }
54
+ }
55
+
56
+ interface Components {
57
+ labels: string[]
58
+ paths: string[]
59
+ }
60
+
61
+ const ROOT = resolve(__dirname, '../components/')
@@ -0,0 +1,16 @@
1
+ import { Connector } from '@toa.io/core'
2
+ import { console } from 'openspan'
3
+
4
+ /**
5
+ * The explorer process. It hosts the introspection components — the map is read
6
+ * through their own operations — and serves the UI.
7
+ */
8
+ export class Explorer extends Connector {
9
+ protected override async open (): Promise<void> {
10
+ console.info('Introspection explorer started')
11
+ }
12
+
13
+ protected override dispose (): void {
14
+ console.info('Introspection explorer is closed')
15
+ }
16
+ }
@@ -0,0 +1,119 @@
1
+ import { Connector } from '@toa.io/core'
2
+ import { DISABLED, environment, component as declaration, settings } from './annotation'
3
+ import { NAMESPACE, UI_PORT } from './const'
4
+ import { describe } from './describe'
5
+ import { Reporter } from './Reporter'
6
+ import { Tenant } from './Tenant'
7
+ import { Composition } from './Composition'
8
+ import { Explorer } from './Explorer'
9
+ import { UI } from './UI'
10
+ import { capture, samplable } from './sample'
11
+ import type { Declaration, Options, Settings } from './annotation'
12
+ import type { Origin, Outcome, Target } from './model'
13
+ import type { Manifest } from '@toa.io/norm'
14
+ import type { Component, Locator, Reply, Request, extensions } from '@toa.io/core'
15
+
16
+ export class Factory implements extensions.Factory {
17
+ private readonly boot: Bootloader
18
+ private readonly options: Options | null
19
+ private readonly settings: Record<string, Settings> = {}
20
+ private reporter: Reporter | null = null
21
+
22
+ public constructor (boot: Bootloader) {
23
+ this.boot = boot
24
+ this.options = environment()
25
+ }
26
+
27
+ public tenant (locator: Locator, decl: Declaration | null, manifest: Manifest): Connector {
28
+ const resolved = settings(locator.namespace, declaration(decl), this.options)
29
+
30
+ this.settings[locator.id] = resolved
31
+
32
+ if (!resolved.enabled || locator.namespace === NAMESPACE)
33
+ return new Connector()
34
+
35
+ return new Tenant(this.collector(), describe(manifest))
36
+ }
37
+
38
+ public component (component: Component): Component {
39
+ const locator = component.locator
40
+ const resolved = this.resolve(locator)
41
+
42
+ if (!resolved.enabled)
43
+ return component
44
+
45
+ const reporter = this.collector()
46
+ const invoke = component.invoke.bind(component)
47
+
48
+ component.invoke = async (endpoint: string, request: Request): Promise<any> => {
49
+ let outcome: Outcome = 'ok'
50
+ let reply: Reply | undefined
51
+
52
+ try {
53
+ reply = await invoke(endpoint, request)
54
+
55
+ if (reply?.exception !== undefined) outcome = 'exception'
56
+ else if (reply?.error !== undefined) outcome = 'error'
57
+
58
+ return reply
59
+ } catch (error) {
60
+ outcome = 'exception'
61
+
62
+ throw error
63
+ } finally {
64
+ // a call that failed is still a connection between two components
65
+ const src: Origin = request?.source ?? UNKNOWN
66
+ const dst: Target = { namespace: locator.namespace, component: locator.name, operation: endpoint }
67
+
68
+ const sample = resolved.samples && samplable(request?.input)
69
+ ? capture(request?.input, outcome)
70
+ : undefined
71
+
72
+ reporter.observe({ src, dst, sample })
73
+ }
74
+ }
75
+
76
+ component.depends(reporter)
77
+
78
+ return component
79
+ }
80
+
81
+ public service (): Connector | null {
82
+ if (this.options === null)
83
+ return null
84
+
85
+ const composition = new Composition(this.boot)
86
+ const explorer = new Explorer()
87
+
88
+ explorer.depends(composition)
89
+
90
+ if (this.options.ui)
91
+ explorer.depends(new UI(UI_PORT))
92
+
93
+ return explorer
94
+ }
95
+
96
+ /**
97
+ * `tenant()` runs before any component is created, so settings are warm.
98
+ * A component booted on its own (without a composition) falls back to
99
+ * the environment, with sampling off.
100
+ */
101
+ private resolve (locator: Locator): Settings {
102
+ if (locator.namespace === NAMESPACE)
103
+ return DISABLED
104
+
105
+ return this.settings[locator.id] ??
106
+ settings(locator.namespace, {}, this.options === null ? null : { ...this.options, samples: false })
107
+ }
108
+
109
+ private collector (): Reporter {
110
+ this.reporter ??= new Reporter(this.boot, this.options!)
111
+
112
+ return this.reporter
113
+ }
114
+ }
115
+
116
+ const UNKNOWN = { service: 'unknown' } as const
117
+
118
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
119
+ export type Bootloader = typeof import('@toa.io/boot')
@@ -0,0 +1,210 @@
1
+ import { Connector, Locator } from '@toa.io/core'
2
+ import { console } from 'openspan'
3
+ import { EDGES, MAX_EDGES, NAMESPACE, NODES } from './const'
4
+ import * as keys from './keys'
5
+ import type { Bootloader } from './Factory'
6
+ import type { Options } from './annotation'
7
+ import type { Edge, Node } from './model'
8
+ import type { Remote } from '@toa.io/core'
9
+
10
+ /**
11
+ * Buffers what a process observes and flushes it into the introspection
12
+ * components.
13
+ *
14
+ * Nothing here is on the critical path. Reaching the explorer is a discovery,
15
+ * which waits as long as it takes, so the collector never holds up a component
16
+ * starting, running or stopping: it buffers until the connection is there, and
17
+ * when it has to choose it gives up on the data rather than on the application.
18
+ */
19
+ export class Reporter extends Connector {
20
+ private readonly boot: Bootloader
21
+ private readonly options: Options
22
+ private readonly nodes = new Map<string, Node>()
23
+ private readonly edges = new Map<string, Edge>()
24
+
25
+ /** Holds a remote only once it is connected and usable. */
26
+ private readonly remotes: Record<string, Remote> = {}
27
+
28
+ private timer: NodeJS.Timeout | null = null
29
+ private flushing: Promise<void> | null = null
30
+ private acquiring = false
31
+ private dropped = 0
32
+
33
+ public constructor (boot: Bootloader, options: Options) {
34
+ super()
35
+
36
+ this.boot = boot
37
+ this.options = options
38
+ }
39
+
40
+ /** The static description of a component. */
41
+ public expose (node: Node): void {
42
+ this.nodes.set(keys.node(node.namespace, node.component), node)
43
+
44
+ void this.flush()
45
+ }
46
+
47
+ /** A call between two components. */
48
+ public observe (observed: Edge): void {
49
+ const id = keys.edge(observed.src, observed.dst)
50
+ const edge = this.edges.get(id)
51
+
52
+ if (edge === undefined) {
53
+ /*
54
+ * `source` arrives over the wire, so the number of distinct edges a process
55
+ * can hold has to be bounded regardless of what peers send — and of whether
56
+ * anyone is there to take them.
57
+ */
58
+ if (this.edges.size >= MAX_EDGES) {
59
+ this.dropped++
60
+
61
+ return
62
+ }
63
+
64
+ this.edges.set(id, observed)
65
+ } else if (observed.sample !== undefined)
66
+ edge.sample = observed.sample
67
+
68
+ if (this.edges.size >= this.options.threshold)
69
+ void this.flush()
70
+ }
71
+
72
+ protected override async open (): Promise<void> {
73
+ // deliberately not awaited: the explorer may not be there yet, or at all
74
+ this.acquire()
75
+
76
+ this.timer = setInterval(() => void this.flush(), this.options.interval * 1000)
77
+ this.timer.unref()
78
+ }
79
+
80
+ protected override async close (): Promise<void> {
81
+ if (this.timer !== null) {
82
+ clearInterval(this.timer)
83
+ this.timer = null
84
+ }
85
+
86
+ await this.flushing
87
+
88
+ if (!this.ready()) {
89
+ this.discard('the explorer was never reached')
90
+
91
+ return
92
+ }
93
+
94
+ // the remotes are still up: dependencies are disconnected after this returns
95
+ await this.dispatch().catch((error: Error) => {
96
+ console.debug('Introspection final flush failed', { message: error.message })
97
+ })
98
+ }
99
+
100
+ private ready (): boolean {
101
+ return NODES in this.remotes && EDGES in this.remotes
102
+ }
103
+
104
+ private async flush (): Promise<void> {
105
+ // a dispatch in flight keeps observations buffered, they join the next batch
106
+ if (this.flushing !== null)
107
+ return
108
+
109
+ if (this.nodes.size === 0 && this.edges.size === 0)
110
+ return
111
+
112
+ if (!this.ready()) {
113
+ this.acquire()
114
+
115
+ if (this.edges.size >= MAX_EDGES)
116
+ this.discard('the explorer is not reachable')
117
+
118
+ return
119
+ }
120
+
121
+ this.flushing = this.dispatch()
122
+ .catch((error: Error) => {
123
+ console.debug('Introspection flush failed', { message: error.message })
124
+ })
125
+ .finally(() => {
126
+ this.flushing = null
127
+ })
128
+
129
+ await this.flushing
130
+ }
131
+
132
+ private discard (reason: string): void {
133
+ const nodes = this.nodes.size
134
+ const edges = this.edges.size + this.dropped
135
+
136
+ this.nodes.clear()
137
+ this.edges.clear()
138
+ this.dropped = 0
139
+
140
+ if (nodes === 0 && edges === 0)
141
+ return
142
+
143
+ console.warn(`Introspection data discarded, ${reason}`, { nodes, edges })
144
+ }
145
+
146
+ private async dispatch (): Promise<void> {
147
+ const nodes = [...this.nodes.entries()]
148
+ const edges = [...this.edges.entries()]
149
+
150
+ this.nodes.clear()
151
+ this.edges.clear()
152
+
153
+ if (this.dropped > 0) {
154
+ console.warn('Introspection edges dropped', { dropped: this.dropped, limit: MAX_EDGES })
155
+ this.dropped = 0
156
+ }
157
+
158
+ await Promise.all([
159
+ this.merge(NODES, 'nodes', nodes),
160
+ this.merge(EDGES, 'edges', edges)
161
+ ])
162
+ }
163
+
164
+ /**
165
+ * A mass transition: every affected object is acquired and committed at once,
166
+ * so a flush is one call per component whatever it carries.
167
+ */
168
+ private async merge (name: string, property: string,
169
+ observed: Array<[string, Node | Edge]>): Promise<void> {
170
+ if (observed.length === 0)
171
+ return
172
+
173
+ const objects: Record<string, Node | Edge> = {}
174
+
175
+ for (const [id, object] of observed)
176
+ objects[id] = object
177
+
178
+ await this.remotes[name].invoke('merge', {
179
+ query: { ids: observed.map(([id]) => id) },
180
+ input: { [property]: objects },
181
+ task: true
182
+ })
183
+ }
184
+
185
+ /** Runs in the background: discovery waits for the explorer as long as it takes. */
186
+ private acquire (): void {
187
+ if (this.acquiring)
188
+ return
189
+
190
+ this.acquiring = true
191
+
192
+ void this.reach().catch((error: Error) => {
193
+ this.acquiring = false
194
+
195
+ console.error('Introspection cannot reach its explorer', { message: error.message })
196
+ })
197
+ }
198
+
199
+ private async reach (): Promise<void> {
200
+ await Promise.all([NODES, EDGES].map(async (name) => {
201
+ const remote = await this.boot.remote(new Locator(name, NAMESPACE))
202
+
203
+ this.depends(remote)
204
+
205
+ await remote.connect()
206
+
207
+ this.remotes[name] = remote
208
+ }))
209
+ }
210
+ }
@@ -0,0 +1,47 @@
1
+ import { setTimeout } from 'node:timers/promises'
2
+ import { Connector } from '@toa.io/core'
3
+ import { ANNOUNCE_INTERVAL } from './const'
4
+ import type { Reporter } from './Reporter'
5
+ import type { Node } from './model'
6
+
7
+ /**
8
+ * Announces the static description of a component.
9
+ *
10
+ * Delivery is guaranteed, so the repeat is not about reliability — it keeps
11
+ * `_updated` fresh, which is how a removed component fades off the map.
12
+ */
13
+ export class Tenant extends Connector {
14
+ private readonly reporter: Reporter
15
+ private readonly node: Node
16
+ private stopped = false
17
+
18
+ public constructor (reporter: Reporter, node: Node) {
19
+ super()
20
+
21
+ this.reporter = reporter
22
+ this.node = node
23
+
24
+ this.depends(reporter)
25
+ }
26
+
27
+ protected override async open (): Promise<void> {
28
+ this.reporter.expose(this.node)
29
+
30
+ void this.announce()
31
+ }
32
+
33
+ protected override dispose (): void {
34
+ this.stopped = true
35
+ }
36
+
37
+ private async announce (): Promise<void> {
38
+ while (!this.stopped) {
39
+ await setTimeout(ANNOUNCE_INTERVAL, undefined, { ref: false })
40
+
41
+ if (this.stopped)
42
+ break
43
+
44
+ this.reporter.expose(this.node)
45
+ }
46
+ }
47
+ }