@oneuptime/common 14.0.6 → 14.0.7

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 (127) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -0,0 +1,129 @@
1
+ import Alert from "../../../Models/DatabaseModels/Alert";
2
+ import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
3
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
4
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
5
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
6
+ import ObjectID from "../../../Types/ObjectID";
7
+ import AlertService from "../../Services/AlertService";
8
+ import ModelPermission from "../../Types/Database/Permissions/Index";
9
+ import Query from "../../Types/Database/Query";
10
+ import QueryHelper from "../../Types/Database/QueryHelper";
11
+ import { applyAlertSelfPrivacyFilter } from "./AlertPrivacyFilter";
12
+ import CaptureSpan from "../Telemetry/CaptureSpan";
13
+
14
+ /*
15
+ * Whether a caller may change the state of a set of alerts - acknowledge
16
+ * them, say - when that change is going to be written for them as root.
17
+ *
18
+ * Changing an alert's state from its own page takes two writes as the caller:
19
+ * a new AlertStateTimeline row (table and column permissions only), and then
20
+ * the alert's currentAlertStateId, which is an Alert UPDATE and so is narrowed
21
+ * by the caller's label scope, Owned scope and alert privacy. Being able to
22
+ * read an alert is not enough: a Viewer who is also an AlertMember for one
23
+ * label can read every alert but change the state of only some. A root write
24
+ * skips both, so this checks both, for every alert, before anything is
25
+ * written - the same rule the Microsoft Teams acknowledge action applies to
26
+ * one alert.
27
+ */
28
+ export default class AlertStateChangeAuthorization {
29
+ @CaptureSpan()
30
+ public static async assertCanChangeStateOfAlerts(data: {
31
+ projectId: ObjectID;
32
+ alertIds: Array<ObjectID>;
33
+ props: DatabaseCommonInteractionProps;
34
+ }): Promise<void> {
35
+ if (data.props.isRoot || data.alertIds.length === 0) {
36
+ return;
37
+ }
38
+
39
+ const probe: AlertStateTimeline = new AlertStateTimeline();
40
+ probe.projectId = data.projectId;
41
+ probe.alertId = data.alertIds[0]!;
42
+ probe.alertStateId = ObjectID.generate();
43
+
44
+ ModelPermission.checkCreatePermissions(
45
+ AlertStateTimeline,
46
+ probe,
47
+ data.props,
48
+ );
49
+
50
+ const alertQuery: Query<Alert> = {
51
+ _id: QueryHelper.any(data.alertIds),
52
+ projectId: data.projectId,
53
+ };
54
+
55
+ /*
56
+ * Labelled block and allow rules are decided per record, from the
57
+ * record's labels.
58
+ */
59
+ const alerts: Array<Alert> = await AlertService.findBy({
60
+ query: alertQuery,
61
+ select: {
62
+ _id: true,
63
+ labels: {
64
+ _id: true,
65
+ name: true,
66
+ },
67
+ },
68
+ limit: LIMIT_PER_PROJECT,
69
+ skip: 0,
70
+ props: {
71
+ isRoot: true,
72
+ },
73
+ });
74
+
75
+ for (const alert of alerts) {
76
+ await ModelPermission.checkUpdatePermissionByModel({
77
+ modelType: Alert,
78
+ fetchModelWithAccessControlIds: async (): Promise<Alert> => {
79
+ return alert;
80
+ },
81
+ props: data.props,
82
+ });
83
+ }
84
+
85
+ /*
86
+ * The caller's own update scope (labels, Owned, privacy) as a query:
87
+ * every alert must still be in it.
88
+ */
89
+ const permittedQuery: Query<Alert> =
90
+ await ModelPermission.checkUpdateQueryPermissions(
91
+ Alert,
92
+ alertQuery,
93
+ {
94
+ currentAlertStateId: ObjectID.getZeroObjectID(),
95
+ },
96
+ data.props,
97
+ );
98
+
99
+ const permittedAlerts: Array<Alert> = await AlertService.findBy({
100
+ query: applyAlertSelfPrivacyFilter(permittedQuery, data.props),
101
+ select: {
102
+ _id: true,
103
+ },
104
+ limit: LIMIT_PER_PROJECT,
105
+ skip: 0,
106
+ props: {
107
+ isRoot: true,
108
+ },
109
+ });
110
+
111
+ const permittedIds: Set<string> = new Set(
112
+ permittedAlerts.map((alert: Alert): string => {
113
+ return alert._id?.toString().toLowerCase() || "";
114
+ }),
115
+ );
116
+
117
+ const allPermitted: boolean = data.alertIds.every(
118
+ (alertId: ObjectID): boolean => {
119
+ return permittedIds.has(alertId.toString().toLowerCase());
120
+ },
121
+ );
122
+
123
+ if (!allPermitted) {
124
+ throw new NotAuthorizedException(
125
+ "You do not have permission to change the state of one or more of these alerts.",
126
+ );
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ AggregateColumn,
3
+ AggregateRow,
4
+ } from "../../Types/Database/AggregateBy";
5
+ import AggregateResultUtil from "../../Types/Database/AggregateResultUtil";
6
+ import Dictionary from "../../../Types/Dictionary";
7
+ import EntitySource from "../../../Types/Telemetry/EntitySource";
8
+ import {
9
+ InventoryLiveness,
10
+ getInventoryLivenessSql,
11
+ } from "../../../Types/Telemetry/InventoryLiveness";
12
+
13
+ /*
14
+ * The Inventory Overview's numbers: the five summary tiles and the per-type
15
+ * breakdown.
16
+ *
17
+ * These used to be folded in the browser from one list read capped at
18
+ * LIMIT_PER_PROJECT, so a project past ten thousand items saw tiles and a
19
+ * breakdown describing whichever ten thousand had been seen most recently,
20
+ * with nothing on the page saying so. They are counted in Postgres instead,
21
+ * in ONE grouped statement: the tiles are sums over the same per-type groups
22
+ * the breakdown lists, so the two are views of one snapshot and cannot
23
+ * disagree.
24
+ *
25
+ * Every tile drills into the Items list, and each filter below is the exact
26
+ * narrowing that tile's list applies — the source facet, or the source facet
27
+ * plus the Stale status facet, whose SQL is the same getInventoryLivenessSql
28
+ * the `inventoryStatus` virtual column is built from.
29
+ *
30
+ * Kept apart from InventoryItemService so the dashboard's tile tests and the
31
+ * Postgres test can use the same columns without loading the service.
32
+ */
33
+ export const INVENTORY_OVERVIEW_GROUP_BY: Array<AggregateColumn> = [
34
+ { expression: `"InventoryItem"."entityType"`, alias: "entityType" },
35
+ ];
36
+
37
+ export const INVENTORY_OVERVIEW_SELECT: Array<AggregateColumn> = [
38
+ { expression: `COUNT(*)`, alias: "itemCount" },
39
+ {
40
+ expression: `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${EntitySource.Discovered}')`,
41
+ alias: "discoveredCount",
42
+ },
43
+ {
44
+ expression: `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${EntitySource.Inventory}')`,
45
+ alias: "mirroredCount",
46
+ },
47
+ {
48
+ expression: `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${EntitySource.Manual}')`,
49
+ alias: "manualCount",
50
+ },
51
+ {
52
+ // Only discovered rows can be stale; the shared rule says NotTracked for the rest.
53
+ expression: `COUNT(*) FILTER (WHERE (${getInventoryLivenessSql(`"InventoryItem"`)}) = '${InventoryLiveness.Stale}')`,
54
+ alias: "staleCount",
55
+ },
56
+ ];
57
+
58
+ export interface InventoryOverviewCounts {
59
+ total: number;
60
+ discovered: number;
61
+ mirrored: number;
62
+ manual: number;
63
+ stale: number;
64
+ // Items per entity type, for the category breakdown. Types that counted zero are absent.
65
+ countsByType: Dictionary<number>;
66
+ }
67
+
68
+ /**
69
+ * Sum the per-type groups into the tile counts, and key the groups by type
70
+ * for the breakdown.
71
+ *
72
+ * `total` counts every row, including rows whose source this build does not
73
+ * recognise — they land in none of the three source buckets, which is why
74
+ * those are not expected to sum to the total. A group with no type still
75
+ * counts towards every tile; it is only left out of the breakdown, which has
76
+ * no row to put it in.
77
+ */
78
+ export function readInventoryOverviewGroups(
79
+ rows: Array<AggregateRow>,
80
+ ): InventoryOverviewCounts {
81
+ const counts: InventoryOverviewCounts = {
82
+ total: 0,
83
+ discovered: 0,
84
+ mirrored: 0,
85
+ manual: 0,
86
+ stale: 0,
87
+ countsByType: {},
88
+ };
89
+
90
+ for (const row of rows) {
91
+ const itemCount: number = AggregateResultUtil.toNumber(row, "itemCount");
92
+
93
+ counts.total += itemCount;
94
+ counts.discovered += AggregateResultUtil.toNumber(row, "discoveredCount");
95
+ counts.mirrored += AggregateResultUtil.toNumber(row, "mirroredCount");
96
+ counts.manual += AggregateResultUtil.toNumber(row, "manualCount");
97
+ counts.stale += AggregateResultUtil.toNumber(row, "staleCount");
98
+
99
+ const entityType: string | null = AggregateResultUtil.toStringOrNull(
100
+ row,
101
+ "entityType",
102
+ );
103
+
104
+ if (entityType && itemCount > 0) {
105
+ counts.countsByType[entityType] =
106
+ (counts.countsByType[entityType] || 0) + itemCount;
107
+ }
108
+ }
109
+
110
+ return counts;
111
+ }
@@ -451,6 +451,37 @@ export default class Response {
451
451
  .send(item);
452
452
  }
453
453
 
454
+ /*
455
+ * Send a body that is ALREADY serialized JSON, as is.
456
+ *
457
+ * For responses built once and handed out many times (the Topology maps
458
+ * cache their serialized payload): sendJsonObjectResponse would parse
459
+ * nothing but re-stringify a multi-megabyte object on every hit, and
460
+ * res.send would hash the whole body for an ETag nobody can use on a POST.
461
+ * writeHead + end skips both, the way sendHtmlResponse does.
462
+ *
463
+ * no-store: the body is one project's inventory, read under the caller's
464
+ * permissions. The audit log gets its size, never the payload.
465
+ */
466
+ @CaptureSpan()
467
+ public static sendJsonStringResponse(
468
+ _req: ExpressRequest,
469
+ res: ExpressResponse,
470
+ json: string,
471
+ ): void {
472
+ const oneUptimeResponse: OneUptimeResponse = res as OneUptimeResponse;
473
+
474
+ Response.setNoCacheHeaders(oneUptimeResponse);
475
+ oneUptimeResponse.logBody = {
476
+ contentType: "application/json",
477
+ characters: json.length,
478
+ };
479
+ oneUptimeResponse.writeHead(200, {
480
+ "Content-Type": "application/json; charset=utf-8",
481
+ });
482
+ oneUptimeResponse.end(json);
483
+ }
484
+
454
485
  @CaptureSpan()
455
486
  public static sendTextResponse(
456
487
  _req: ExpressRequest,
@@ -0,0 +1,229 @@
1
+ import TooManyRequestsException from "../../../Types/Exception/TooManyRequestsException";
2
+
3
+ /*
4
+ * Bounds the Topology API's database work in one API process.
5
+ *
6
+ * Every Topology request reads in its own snapshot on one pooled connection
7
+ * (TopologyQueries.inReadOnlySnapshot), and a map build is among the heaviest
8
+ * reads in the app — a whole-inventory pass that can take seconds on a large
9
+ * project. The response cache shares a build between viewers of the same
10
+ * (project, minute), but callers choose the minute and the drawer and
11
+ * collection reads are not cached at all, so nothing else stops a burst of
12
+ * them from taking the connection pool (shared with ingest, alerting and
13
+ * every other request on the process) and the database CPU with it.
14
+ *
15
+ * So at most `maxRunning` of them run at once per process, and at most
16
+ * `maxRunningPerProject` for any one project, so one busy project cannot
17
+ * occupy every slot. The rest wait in arrival order WITHOUT holding a
18
+ * connection: a released slot goes to the first waiter whose project is
19
+ * under its own limit, so a project at its limit never holds up another
20
+ * project queued behind it. The queue is bounded too — per process and per
21
+ * project — and a request that finds it full, or waits longer than
22
+ * `maxWaitMs`, is answered 429 (TooManyRequestsException) at once: the
23
+ * Dashboard shows "busy, try again" rather than a request that hangs.
24
+ *
25
+ * A request whose client has gone (the drawer aborts the previous entity's
26
+ * load on every click) gives up its queue place at once and never starts:
27
+ * otherwise one person clicking through the drawer would queue dead reads
28
+ * ahead of the one they are waiting for and fill their project's queue.
29
+ *
30
+ * Per process, like the response cache; there is no cross-process
31
+ * coordination, so the cluster-wide bound is this times the API replicas.
32
+ */
33
+
34
+ export const TOPOLOGY_BUSY_MESSAGE: string =
35
+ "The topology service is busy. Try again in a moment.";
36
+
37
+ /* Sent as Retry-After with a 429 from the Topology API. */
38
+ export const TOPOLOGY_BUSY_RETRY_AFTER_SECONDS: number = 5;
39
+
40
+ export interface TopologyConcurrencyLimits {
41
+ /* Topology reads (one snapshot each) running at once in this process. */
42
+ maxRunning: number;
43
+ /* Of those, at most this many for one project. */
44
+ maxRunningPerProject: number;
45
+ /* Requests waiting for a slot in this process; one more is a 429. */
46
+ maxWaiting: number;
47
+ /* Of those, at most this many for one project, so it cannot fill the queue. */
48
+ maxWaitingPerProject: number;
49
+ /* Longest a request waits for a slot before it is answered 429. */
50
+ maxWaitMs: number;
51
+ }
52
+
53
+ export const TOPOLOGY_CONCURRENCY_LIMITS: TopologyConcurrencyLimits = {
54
+ maxRunning: 4,
55
+ maxRunningPerProject: 2,
56
+ maxWaiting: 64,
57
+ maxWaitingPerProject: 16,
58
+ maxWaitMs: 30_000,
59
+ };
60
+
61
+ /* The caller went away before its read started; nobody is waiting for it. */
62
+ export class TopologyRequestAbandonedError extends Error {
63
+ public constructor() {
64
+ super("The Topology request was abandoned by its client.");
65
+ this.name = "TopologyRequestAbandonedError";
66
+ }
67
+ }
68
+
69
+ interface Waiter {
70
+ projectId: string;
71
+ start: () => void;
72
+ timer: ReturnType<typeof setTimeout> | null;
73
+ detach: () => void;
74
+ }
75
+
76
+ export class TopologyConcurrencyLimiter {
77
+ private running: number = 0;
78
+ private runningByProject: Map<string, number> = new Map<string, number>();
79
+ /*
80
+ * Arrival order. Invariant between calls: no waiter here could start (the
81
+ * process is full, or its project is), so a new request that can start
82
+ * never overtakes one that could have.
83
+ */
84
+ private waiting: Array<Waiter> = [];
85
+
86
+ public constructor(
87
+ private limits: TopologyConcurrencyLimits = TOPOLOGY_CONCURRENCY_LIMITS,
88
+ ) {}
89
+
90
+ /*
91
+ * Runs `work` once a slot for `projectId` is free, and frees it when the
92
+ * work settles, however it settles. Throws TooManyRequestsException
93
+ * without running `work` when the queue is full or the wait too long, and
94
+ * TopologyRequestAbandonedError without running it when `signal` fires
95
+ * first (a read that has started runs to completion).
96
+ */
97
+ public async run<T>(
98
+ projectId: string,
99
+ work: () => Promise<T>,
100
+ signal?: AbortSignal | undefined,
101
+ ): Promise<T> {
102
+ await this.acquire(projectId, signal);
103
+ try {
104
+ if (signal?.aborted) {
105
+ throw new TopologyRequestAbandonedError();
106
+ }
107
+ return await work();
108
+ } finally {
109
+ this.release(projectId);
110
+ }
111
+ }
112
+
113
+ public runningCount(projectId?: string): number {
114
+ if (projectId === undefined) {
115
+ return this.running;
116
+ }
117
+ return this.runningByProject.get(projectId) || 0;
118
+ }
119
+
120
+ public waitingCount(projectId?: string): number {
121
+ if (projectId === undefined) {
122
+ return this.waiting.length;
123
+ }
124
+ return this.waiting.filter((waiter: Waiter): boolean => {
125
+ return waiter.projectId === projectId;
126
+ }).length;
127
+ }
128
+
129
+ private canStart(projectId: string): boolean {
130
+ return (
131
+ this.running < this.limits.maxRunning &&
132
+ this.runningCount(projectId) < this.limits.maxRunningPerProject
133
+ );
134
+ }
135
+
136
+ private take(projectId: string): void {
137
+ this.running++;
138
+ this.runningByProject.set(projectId, this.runningCount(projectId) + 1);
139
+ }
140
+
141
+ private acquire(
142
+ projectId: string,
143
+ signal: AbortSignal | undefined,
144
+ ): Promise<void> {
145
+ if (signal?.aborted) {
146
+ return Promise.reject(new TopologyRequestAbandonedError());
147
+ }
148
+
149
+ if (this.canStart(projectId)) {
150
+ this.take(projectId);
151
+ return Promise.resolve();
152
+ }
153
+
154
+ if (
155
+ this.waiting.length >= this.limits.maxWaiting ||
156
+ this.waitingCount(projectId) >= this.limits.maxWaitingPerProject
157
+ ) {
158
+ return Promise.reject(
159
+ new TooManyRequestsException(TOPOLOGY_BUSY_MESSAGE),
160
+ );
161
+ }
162
+
163
+ return new Promise<void>(
164
+ (resolve: () => void, reject: (error: Error) => void): void => {
165
+ /* Leaves the queue (if still in it) and rejects with `error`. */
166
+ const leave: (error: Error) => void = (error: Error): void => {
167
+ const index: number = this.waiting.indexOf(waiter);
168
+ if (index >= 0) {
169
+ this.waiting.splice(index, 1);
170
+ waiter.detach();
171
+ reject(error);
172
+ }
173
+ };
174
+ const onAbort: () => void = (): void => {
175
+ leave(new TopologyRequestAbandonedError());
176
+ };
177
+ const waiter: Waiter = {
178
+ projectId,
179
+ start: resolve,
180
+ timer: null,
181
+ detach: (): void => {
182
+ if (waiter.timer) {
183
+ clearTimeout(waiter.timer);
184
+ }
185
+ signal?.removeEventListener("abort", onAbort);
186
+ },
187
+ };
188
+ if (Number.isFinite(this.limits.maxWaitMs)) {
189
+ waiter.timer = setTimeout((): void => {
190
+ leave(new TooManyRequestsException(TOPOLOGY_BUSY_MESSAGE));
191
+ }, this.limits.maxWaitMs);
192
+ }
193
+ signal?.addEventListener("abort", onAbort);
194
+ this.waiting.push(waiter);
195
+ },
196
+ );
197
+ }
198
+
199
+ private release(projectId: string): void {
200
+ this.running--;
201
+ const remaining: number = this.runningCount(projectId) - 1;
202
+ if (remaining > 0) {
203
+ this.runningByProject.set(projectId, remaining);
204
+ } else {
205
+ this.runningByProject.delete(projectId);
206
+ }
207
+ this.startWaiters();
208
+ }
209
+
210
+ /* Hands free slots to waiters in arrival order, skipping full projects. */
211
+ private startWaiters(): void {
212
+ if (this.waiting.length === 0) {
213
+ return;
214
+ }
215
+ const stillWaiting: Array<Waiter> = [];
216
+ for (const waiter of this.waiting) {
217
+ if (this.canStart(waiter.projectId)) {
218
+ this.take(waiter.projectId);
219
+ waiter.detach();
220
+ waiter.start();
221
+ } else {
222
+ stillWaiting.push(waiter);
223
+ }
224
+ }
225
+ this.waiting = stillWaiting;
226
+ }
227
+ }
228
+
229
+ export default new TopologyConcurrencyLimiter();