@checkstack/healthcheck-backend 1.14.0 → 1.16.0

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.
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { CollectorAssertion } from "@checkstack/healthcheck-common";
3
- import { evaluateCollectorAssertions } from "./collector-assertions";
3
+ import {
4
+ ASSERTION_ACTUAL_MAX_LENGTH,
5
+ computeAssertionKey,
6
+ } from "@checkstack/healthcheck-common";
7
+ import {
8
+ evaluateCollectorAssertionOutcomes,
9
+ evaluateCollectorAssertions,
10
+ } from "./collector-assertions";
4
11
 
5
12
  /** The HTTP Request collector's result shape, as the executor sees it. */
6
13
  const httpResult = (body: string): Record<string, unknown> => ({
@@ -274,3 +281,92 @@ describe("evaluateCollectorAssertions", () => {
274
281
  });
275
282
  });
276
283
  });
284
+
285
+ describe("evaluateCollectorAssertionOutcomes", () => {
286
+ it("evaluates ALL assertions instead of short-circuiting on failure", () => {
287
+ const result = httpResult('{"status":"degraded"}');
288
+ const { outcomes, firstFailureMessage } =
289
+ evaluateCollectorAssertionOutcomes({
290
+ assertions: [
291
+ { field: "statusCode", operator: "equals", value: 500 }, // fails
292
+ { field: "success", operator: "isTrue" }, // passes
293
+ jsonPathAssertion("$.status", "equals", "ok"), // fails
294
+ ],
295
+ result,
296
+ });
297
+ expect(outcomes.length).toBe(3);
298
+ expect(outcomes.map((o) => o.passed)).toEqual([false, true, false]);
299
+ // The legacy string still reports the FIRST failure only.
300
+ expect(firstFailureMessage).toBe("statusCode equals 500");
301
+ });
302
+
303
+ it("captures observed values for plain and JSONPath assertions", () => {
304
+ const result = httpResult('{"status":"ok","items":[1,2]}');
305
+ const { outcomes } = evaluateCollectorAssertionOutcomes({
306
+ assertions: [
307
+ { field: "statusCode", operator: "equals", value: 200 },
308
+ jsonPathAssertion("$.status", "equals", "ok"),
309
+ jsonPathAssertion("$.items", "lengthEquals", 2),
310
+ ],
311
+ result,
312
+ });
313
+ expect(outcomes[0].actual).toBe("200");
314
+ expect(outcomes[1].actual).toBe("ok");
315
+ expect(outcomes[2].actual).toBe("[1,2]");
316
+ expect(outcomes.every((o) => o.passed)).toBe(true);
317
+ });
318
+
319
+ it("truncates long observed values", () => {
320
+ const longBody = JSON.stringify({ blob: "x".repeat(5000) });
321
+ const { outcomes } = evaluateCollectorAssertionOutcomes({
322
+ assertions: [
323
+ { field: "body", operator: "contains", value: "x" },
324
+ ],
325
+ result: httpResult(longBody),
326
+ });
327
+ expect(outcomes[0].passed).toBe(true);
328
+ expect(outcomes[0].actual?.length).toBe(ASSERTION_ACTUAL_MAX_LENGTH);
329
+ });
330
+
331
+ it("fail-closed diagnostics become failed outcomes with messages", () => {
332
+ const { outcomes, firstFailureMessage } =
333
+ evaluateCollectorAssertionOutcomes({
334
+ assertions: [
335
+ { field: "body.$", jsonPath: " ", operator: "exists" },
336
+ jsonPathAssertion("$.status", "equals", "ok"),
337
+ ],
338
+ result: httpResult("not json"),
339
+ });
340
+ expect(outcomes[0].passed).toBe(false);
341
+ expect(outcomes[0].message).toBe("missing JSONPath expression");
342
+ expect(outcomes[1].passed).toBe(false);
343
+ expect(outcomes[1].message).toBe('field "body" is not valid JSON');
344
+ expect(firstFailureMessage).toBe(
345
+ "body.$ exists (missing JSONPath expression)",
346
+ );
347
+ });
348
+
349
+ it("outcomes carry the canonical assertion identity key", () => {
350
+ const assertion: CollectorAssertion = {
351
+ field: "statusCode",
352
+ operator: "equals",
353
+ value: 200,
354
+ };
355
+ const { outcomes } = evaluateCollectorAssertionOutcomes({
356
+ assertions: [assertion],
357
+ result: httpResult("{}"),
358
+ });
359
+ expect(outcomes[0].key).toBe(computeAssertionKey({ assertion }));
360
+ expect(outcomes[0].value).toBe("200");
361
+ });
362
+
363
+ it("no assertions yields an empty evaluation", () => {
364
+ const { outcomes, firstFailureMessage } =
365
+ evaluateCollectorAssertionOutcomes({
366
+ assertions: undefined,
367
+ result: httpResult("{}"),
368
+ });
369
+ expect(outcomes).toEqual([]);
370
+ expect(firstFailureMessage).toBeUndefined();
371
+ });
372
+ });
@@ -1,8 +1,12 @@
1
+ import { evaluateAssertion } from "@checkstack/backend-api";
2
+ import type {
3
+ AssertionOutcome,
4
+ CollectorAssertion,
5
+ } from "@checkstack/healthcheck-common";
1
6
  import {
2
- evaluateAssertions,
3
- evaluateJsonPathAssertions,
4
- } from "@checkstack/backend-api";
5
- import type { CollectorAssertion } from "@checkstack/healthcheck-common";
7
+ computeAssertionKey,
8
+ truncateActual,
9
+ } from "@checkstack/healthcheck-common";
6
10
  import { extractErrorMessage } from "@checkstack/common";
7
11
  import { JSONPath } from "jsonpath-plus";
8
12
 
@@ -69,9 +73,23 @@ function formatFailure({
69
73
  return detail ? `${base} (${detail})` : base;
70
74
  }
71
75
 
76
+ /** All outcomes of a collector's assertions plus the legacy failure string. */
77
+ export interface CollectorAssertionEvaluation {
78
+ /** One structured outcome per configured assertion, in config order. */
79
+ outcomes: AssertionOutcome[];
80
+ /**
81
+ * The FIRST failing assertion's message, formatted exactly like the legacy
82
+ * `_assertionFailed` string. Undefined when everything passed.
83
+ */
84
+ firstFailureMessage?: string;
85
+ }
86
+
72
87
  /**
73
- * Evaluate a collector's assertions - plain field assertions AND JSONPath
74
- * assertions - against its result, in the order they were configured.
88
+ * Evaluate ALL of a collector's assertions - plain field assertions AND
89
+ * JSONPath assertions - against its result, in the order they were
90
+ * configured, returning a structured outcome per assertion (pass AND fail;
91
+ * this is what makes assertions analyzable rather than only visible on
92
+ * failure).
75
93
  *
76
94
  * Plain assertions compare `result[field]` directly (unchanged behaviour).
77
95
  * JSONPath assertions parse the SOURCE field (e.g. `body` for the field
@@ -79,18 +97,15 @@ function formatFailure({
79
97
  * apply the operator to the extracted value. Fail-closed: a missing
80
98
  * expression, a non-JSON source value, or an invalid/eval-blocked path fails
81
99
  * the assertion (with a diagnostic suffix) - it never fails the collector.
82
- *
83
- * Returns the failure message of the FIRST failing assertion, or `undefined`
84
- * when all pass.
85
100
  */
86
- export function evaluateCollectorAssertions({
101
+ export function evaluateCollectorAssertionOutcomes({
87
102
  assertions,
88
103
  result,
89
104
  }: {
90
105
  assertions: CollectorAssertion[] | undefined;
91
106
  result: Record<string, unknown>;
92
- }): string | undefined {
93
- if (!assertions?.length) return undefined;
107
+ }): CollectorAssertionEvaluation {
108
+ if (!assertions?.length) return { outcomes: [] };
94
109
 
95
110
  // Parse each JSON source field at most once, not once per assertion.
96
111
  const parsedSources = new Map<string, { json?: unknown; error?: string }>();
@@ -116,19 +131,72 @@ export function evaluateCollectorAssertions({
116
131
  return entry;
117
132
  };
118
133
 
134
+ const outcomes: AssertionOutcome[] = [];
135
+ let firstFailureMessage: string | undefined;
136
+
137
+ const baseOutcome = (assertion: CollectorAssertion) => ({
138
+ key: computeAssertionKey({ assertion }),
139
+ field: assertion.field,
140
+ jsonPath: assertion.jsonPath?.trim() || undefined,
141
+ operator: assertion.operator,
142
+ value:
143
+ assertion.value === undefined ? undefined : String(assertion.value),
144
+ });
145
+
146
+ const recordFailure = ({
147
+ assertion,
148
+ actual,
149
+ message,
150
+ legacyMessage,
151
+ }: {
152
+ assertion: CollectorAssertion;
153
+ actual?: unknown;
154
+ message: string;
155
+ legacyMessage: string;
156
+ }) => {
157
+ outcomes.push({
158
+ ...baseOutcome(assertion),
159
+ passed: false,
160
+ actual: actual === undefined ? undefined : truncateActual({ value: actual }),
161
+ message,
162
+ });
163
+ if (firstFailureMessage === undefined) firstFailureMessage = legacyMessage;
164
+ };
165
+
119
166
  for (const assertion of assertions) {
120
167
  if (!isJsonPathAssertion(assertion)) {
121
- const failed = evaluateAssertions([assertion], result);
122
- if (failed) return formatFailure({ assertion: failed });
168
+ const evaluated = evaluateAssertion(assertion, result);
169
+ if (evaluated.passed) {
170
+ outcomes.push({
171
+ ...baseOutcome(assertion),
172
+ passed: true,
173
+ actual:
174
+ evaluated.actual === undefined
175
+ ? undefined
176
+ : truncateActual({ value: evaluated.actual }),
177
+ });
178
+ } else {
179
+ recordFailure({
180
+ assertion,
181
+ actual: evaluated.actual,
182
+ message: evaluated.message ?? formatFailure({ assertion }),
183
+ legacyMessage: formatFailure({ assertion }),
184
+ });
185
+ }
123
186
  continue;
124
187
  }
125
188
 
126
189
  const path = assertion.jsonPath?.trim();
127
190
  if (!path) {
128
- return formatFailure({
191
+ recordFailure({
129
192
  assertion,
130
- detail: "missing JSONPath expression",
193
+ message: "missing JSONPath expression",
194
+ legacyMessage: formatFailure({
195
+ assertion,
196
+ detail: "missing JSONPath expression",
197
+ }),
131
198
  });
199
+ continue;
132
200
  }
133
201
 
134
202
  const sourceField = assertion.field.endsWith(JSONPATH_FIELD_SUFFIX)
@@ -136,34 +204,73 @@ export function evaluateCollectorAssertions({
136
204
  : assertion.field;
137
205
  const source = parseSource(sourceField);
138
206
  if (source.error) {
139
- return formatFailure({ assertion, detail: source.error });
207
+ recordFailure({
208
+ assertion,
209
+ message: source.error,
210
+ legacyMessage: formatFailure({ assertion, detail: source.error }),
211
+ });
212
+ continue;
140
213
  }
141
214
 
142
215
  try {
143
- const failed = evaluateJsonPathAssertions(
144
- [
145
- {
146
- path,
147
- operator: assertion.operator,
148
- value:
149
- assertion.value === undefined
150
- ? undefined
151
- : String(assertion.value),
152
- },
153
- ],
154
- source.json,
155
- extractJsonPath,
216
+ // Extract once, then reuse the shared operator engine on a synthetic
217
+ // one-field record so the outcome carries the observed value.
218
+ const extracted = extractJsonPath(path, source.json);
219
+ const evaluated = evaluateAssertion(
220
+ {
221
+ field: "__jsonpath__",
222
+ operator: assertion.operator,
223
+ value:
224
+ assertion.value === undefined
225
+ ? undefined
226
+ : String(assertion.value),
227
+ },
228
+ { __jsonpath__: extracted },
156
229
  );
157
- if (failed) return formatFailure({ assertion });
230
+ if (evaluated.passed) {
231
+ outcomes.push({
232
+ ...baseOutcome(assertion),
233
+ passed: true,
234
+ actual:
235
+ extracted === undefined
236
+ ? undefined
237
+ : truncateActual({ value: extracted }),
238
+ });
239
+ } else {
240
+ recordFailure({
241
+ assertion,
242
+ actual: extracted,
243
+ message: evaluated.message ?? formatFailure({ assertion }),
244
+ legacyMessage: formatFailure({ assertion }),
245
+ });
246
+ }
158
247
  } catch (error) {
159
248
  // jsonpath-plus rejects malformed paths and (with eval disabled)
160
249
  // filter/script expressions by throwing.
161
- return formatFailure({
250
+ const detail = `invalid JSONPath: ${extractErrorMessage(error)}`;
251
+ recordFailure({
162
252
  assertion,
163
- detail: `invalid JSONPath: ${extractErrorMessage(error)}`,
253
+ message: detail,
254
+ legacyMessage: formatFailure({ assertion, detail }),
164
255
  });
165
256
  }
166
257
  }
167
258
 
168
- return undefined;
259
+ return { outcomes, firstFailureMessage };
260
+ }
261
+
262
+ /**
263
+ * Legacy single-string view of {@link evaluateCollectorAssertionOutcomes}:
264
+ * the failure message of the FIRST failing assertion, or `undefined` when all
265
+ * pass. Kept for callers that only need the `_assertionFailed` string.
266
+ */
267
+ export function evaluateCollectorAssertions({
268
+ assertions,
269
+ result,
270
+ }: {
271
+ assertions: CollectorAssertion[] | undefined;
272
+ result: Record<string, unknown>;
273
+ }): string | undefined {
274
+ return evaluateCollectorAssertionOutcomes({ assertions, result })
275
+ .firstFailureMessage;
169
276
  }
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configSecret, Versioned } from "@checkstack/backend-api";
4
+ import type { InternalSecretsService } from "@checkstack/secrets-backend";
5
+ import { internalSecretName } from "@checkstack/secrets-common";
6
+ import { backfillConfigSecrets } from "./config-secrets-backfill";
7
+ import {
8
+ healthcheckConfigLockKey,
9
+ isHealthcheckSecretMarker,
10
+ } from "./config-secrets";
11
+
12
+ const collectorSchema = z.object({
13
+ url: z.string(),
14
+ password: configSecret({ id: "password" }).optional(),
15
+ });
16
+
17
+ function fakeInternalSecrets(): InternalSecretsService & {
18
+ store: Map<string, string>;
19
+ } {
20
+ const store = new Map<string, string>();
21
+ return {
22
+ store,
23
+ async set({ parts, value }) {
24
+ store.set(internalSecretName(...parts), value);
25
+ },
26
+ async get({ parts }) {
27
+ return store.get(internalSecretName(...parts));
28
+ },
29
+ async delete({ parts }) {
30
+ store.delete(internalSecretName(...parts));
31
+ },
32
+ };
33
+ }
34
+
35
+ const noopLogger = {
36
+ debug: mock(() => {}),
37
+ info: mock(() => {}),
38
+ warn: mock(() => {}),
39
+ error: mock(() => {}),
40
+ } as never;
41
+
42
+ describe("backfillConfigSecrets", () => {
43
+ it("migrates a registered collector's inline secret even under an UNREGISTERED strategy, per config lock", async () => {
44
+ // One legacy row: strategy plugin is gone, but the collector plugin is
45
+ // present and its config carries an inline plaintext secret.
46
+ let row: Record<string, unknown> = {
47
+ id: "cfg-bf",
48
+ strategyId: "gone.strategy",
49
+ config: { url: "https://x", password: "strategy-plain" }, // no schema -> left as-is
50
+ collectors: [
51
+ {
52
+ id: "c1",
53
+ collectorId: "reg.collector",
54
+ config: { url: "https://y", password: "collector-plain" },
55
+ },
56
+ ],
57
+ };
58
+ const updates: Record<string, unknown>[] = [];
59
+ const db = {
60
+ // Initial scan: `db.select().from(table)` awaited directly.
61
+ // Per-row re-read: `db.select().from(table).where(eq(...))`.
62
+ select: () => ({
63
+ from: () =>
64
+ Object.assign(Promise.resolve([row]), {
65
+ where: () => Promise.resolve([row]),
66
+ }),
67
+ }),
68
+ update: () => ({
69
+ set: (set: Record<string, unknown>) => {
70
+ updates.push(set);
71
+ row = { ...row, ...set };
72
+ return { where: () => Promise.resolve(undefined) };
73
+ },
74
+ }),
75
+ };
76
+
77
+ const registry = { getStrategy: mock(() => undefined) };
78
+ const collectorRegistry = {
79
+ getCollector: mock((id: string) =>
80
+ id === "reg.collector"
81
+ ? { collector: { config: new Versioned({ version: 1, schema: collectorSchema }) } }
82
+ : undefined,
83
+ ),
84
+ };
85
+
86
+ const lockKeys: string[] = [];
87
+ const advisoryLock = {
88
+ tryAcquire: mock(async () => ({ release: mock(async () => {}) })),
89
+ withXactLock: async ({ key, fn }: { key: string; fn: () => Promise<unknown> }) => {
90
+ lockKeys.push(key);
91
+ return fn();
92
+ },
93
+ };
94
+
95
+ const internalSecrets = fakeInternalSecrets();
96
+
97
+ await backfillConfigSecrets({
98
+ db: db as never,
99
+ registry: registry as never,
100
+ collectorRegistry: collectorRegistry as never,
101
+ internalSecrets,
102
+ advisoryLock: advisoryLock as never,
103
+ logger: noopLogger,
104
+ });
105
+
106
+ // Per-config lock was taken for the row.
107
+ expect(lockKeys).toEqual([healthcheckConfigLockKey("cfg-bf")]);
108
+
109
+ // The collector's inline secret was migrated to a marker + the store.
110
+ const persistedCollectors = updates.at(-1)?.collectors as Array<{
111
+ config: Record<string, unknown>;
112
+ }>;
113
+ expect(
114
+ isHealthcheckSecretMarker(persistedCollectors[0].config.password as string),
115
+ ).toBe(true);
116
+ expect([...internalSecrets.store.values()]).toContain("collector-plain");
117
+
118
+ // The strategy config (no schema to walk) is left untouched - never wiped,
119
+ // and its legacy plaintext is NOT migrated (nothing else we can do).
120
+ const persistedConfig = updates.at(-1)?.config as Record<string, unknown>;
121
+ expect(persistedConfig.password).toBe("strategy-plain");
122
+ expect([...internalSecrets.store.values()]).not.toContain("strategy-plain");
123
+ });
124
+
125
+ it("skips the scan entirely when another pod holds the backfill lock", async () => {
126
+ const advisoryLock = {
127
+ tryAcquire: mock(async () => undefined), // lock not acquired
128
+ withXactLock: mock(async () => {
129
+ throw new Error("must not run when the global lock is unavailable");
130
+ }),
131
+ };
132
+ await backfillConfigSecrets({
133
+ db: {} as never,
134
+ registry: {} as never,
135
+ collectorRegistry: {} as never,
136
+ internalSecrets: fakeInternalSecrets(),
137
+ advisoryLock: advisoryLock as never,
138
+ logger: noopLogger,
139
+ });
140
+ expect(advisoryLock.withXactLock).not.toHaveBeenCalled();
141
+ });
142
+ });
@@ -0,0 +1,129 @@
1
+ import { eq } from "drizzle-orm";
2
+ import type {
3
+ AdvisoryLockService,
4
+ HealthCheckRegistry,
5
+ CollectorRegistry,
6
+ Logger,
7
+ SafeDatabase,
8
+ } from "@checkstack/backend-api";
9
+ import type { InternalSecretsService } from "@checkstack/secrets-backend";
10
+ import type { CollectorConfigEntry } from "@checkstack/healthcheck-common";
11
+ import { healthCheckConfigurations } from "./schema";
12
+ import * as schema from "./schema";
13
+ import {
14
+ extractConfigurationSecrets,
15
+ healthcheckConfigLockKey,
16
+ } from "./config-secrets";
17
+
18
+ /**
19
+ * One-time (idempotent) boot backfill: move inline `x-secret` values that
20
+ * pre-date the config-secrets channel out of stored health-check
21
+ * configurations and into internal secrets.
22
+ *
23
+ * Rows written after the channel shipped hold only markers / `${{ secrets.* }}`
24
+ * references, which the extraction walk skips - so re-running is a no-op and
25
+ * the job is safe to run on EVERY boot. A cross-pod advisory lock ensures a
26
+ * single pod performs the scan; the others skip (the winner's result is in
27
+ * the shared DB either way).
28
+ *
29
+ * Fail-open per row: one row with an unregistered strategy (plugin removed)
30
+ * or a broken shape must not wedge boot - it is logged and left as-is; the
31
+ * executor treats its bare literal exactly as before (legacy pass-through).
32
+ */
33
+ export async function backfillConfigSecrets({
34
+ db,
35
+ registry,
36
+ collectorRegistry,
37
+ internalSecrets,
38
+ advisoryLock,
39
+ logger,
40
+ }: {
41
+ db: SafeDatabase<typeof schema>;
42
+ registry: HealthCheckRegistry;
43
+ collectorRegistry: CollectorRegistry;
44
+ internalSecrets: InternalSecretsService;
45
+ advisoryLock: AdvisoryLockService;
46
+ logger: Logger;
47
+ }): Promise<void> {
48
+ const lock = await advisoryLock.tryAcquire("healthcheck:config-secrets-backfill");
49
+ if (!lock) {
50
+ logger.debug("Config-secrets backfill already running on another pod, skipping");
51
+ return;
52
+ }
53
+
54
+ try {
55
+ const rows = await db.select().from(healthCheckConfigurations);
56
+ let migratedRows = 0;
57
+ let movedValues = 0;
58
+
59
+ for (const { id: rowId } of rows) {
60
+ // Serialize each row's read-modify-write under the SAME per-config lock
61
+ // updateConfiguration uses, and re-read the row INSIDE the lock. Without
62
+ // this the backfill could write back a pre-lock snapshot over a concurrent
63
+ // edit - resurrecting a just-rotated secret and reverting the edit.
64
+ await advisoryLock.withXactLock({
65
+ key: healthcheckConfigLockKey(rowId),
66
+ fn: async () => {
67
+ const [row] = await db
68
+ .select()
69
+ .from(healthCheckConfigurations)
70
+ .where(eq(healthCheckConfigurations.id, rowId));
71
+ if (!row) return; // deleted between the scan and the lock
72
+
73
+ // An unregistered strategy has no schema: its own strategy-level
74
+ // legacy secrets stay as-is (pass-through, as before), but we still
75
+ // migrate every REGISTERED collector's inline secrets rather than
76
+ // skipping the whole row - leaving those plaintext would defeat the
77
+ // backfill. extractConfigurationSecrets handles the undefined schema.
78
+ const strategySchema =
79
+ registry.getStrategy(row.strategyId)?.config.schema;
80
+ if (!strategySchema) {
81
+ logger.warn(
82
+ `Config-secrets backfill: strategy ${row.strategyId} not registered; leaving its strategy config as-is and migrating collector secrets for configuration ${row.id}`,
83
+ );
84
+ }
85
+
86
+ try {
87
+ const collectors: CollectorConfigEntry[] | undefined =
88
+ row.collectors ?? undefined;
89
+ const extracted = await extractConfigurationSecrets({
90
+ configurationId: row.id,
91
+ strategySchema,
92
+ config: row.config as Record<string, unknown>,
93
+ collectors,
94
+ getCollectorSchema: (collectorId) =>
95
+ collectorRegistry.getCollector(collectorId)?.collector.config
96
+ .schema,
97
+ internalSecrets,
98
+ });
99
+ if (extracted.extracted === 0) return;
100
+
101
+ await db
102
+ .update(healthCheckConfigurations)
103
+ .set({
104
+ config: extracted.config,
105
+ collectors: extracted.collectors,
106
+ updatedAt: new Date(),
107
+ })
108
+ .where(eq(healthCheckConfigurations.id, row.id));
109
+ migratedRows++;
110
+ movedValues += extracted.extracted;
111
+ } catch (error) {
112
+ logger.warn(
113
+ `Config-secrets backfill: failed for configuration ${row.id}, leaving as-is`,
114
+ error,
115
+ );
116
+ }
117
+ },
118
+ });
119
+ }
120
+
121
+ if (migratedRows > 0) {
122
+ logger.info(
123
+ `Config-secrets backfill: moved ${movedValues} inline secret value(s) from ${migratedRows} configuration(s) into the internal secret store`,
124
+ );
125
+ }
126
+ } finally {
127
+ await lock.release();
128
+ }
129
+ }