@malloy-publisher/server 0.0.198 → 0.0.200

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 (75) hide show
  1. package/build.ts +30 -1
  2. package/dist/app/api-doc.yaml +127 -111
  3. package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-CgKNjySu.js} +1 -1
  4. package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
  5. package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-CAwb8U82.js} +2 -2
  6. package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-C0Uevsw9.js} +1 -1
  7. package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-Cu-u9k1g.js} +1 -1
  8. package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-DVwPh2Ql.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DW38R2Zv.js} +1 -1
  10. package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
  11. package/dist/app/assets/{index-DL6BZTuw.js → index-BGdcKsFF.js} +1 -1
  12. package/dist/app/assets/{index-DNofXMxi.js → index-CTx4v4_3.js} +1 -1
  13. package/dist/app/assets/index-DE6d5jEy.js +452 -0
  14. package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-C1Mi1uRm.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +57 -36
  17. package/dist/package_load_worker.mjs +12213 -0
  18. package/dist/server.mjs +4198 -3648
  19. package/package.json +2 -3
  20. package/src/config.spec.ts +246 -0
  21. package/src/config.ts +121 -1
  22. package/src/constants.ts +84 -1
  23. package/src/controller/compile.controller.ts +3 -1
  24. package/src/controller/connection.controller.spec.ts +803 -0
  25. package/src/controller/connection.controller.ts +207 -20
  26. package/src/controller/model.controller.ts +19 -1
  27. package/src/controller/query.controller.ts +22 -6
  28. package/src/controller/watch-mode.controller.ts +11 -2
  29. package/src/errors.spec.ts +44 -0
  30. package/src/errors.ts +34 -0
  31. package/src/health.spec.ts +90 -0
  32. package/src/health.ts +88 -45
  33. package/src/heap_check.spec.ts +144 -0
  34. package/src/heap_check.ts +144 -0
  35. package/src/instrumentation.ts +50 -0
  36. package/src/mcp/handler_utils.ts +14 -0
  37. package/src/mcp/tools/execute_query_tool.ts +52 -10
  38. package/src/oom_guards.integration.spec.ts +261 -0
  39. package/src/package_load/package_load_pool.spec.ts +252 -0
  40. package/src/package_load/package_load_pool.ts +920 -0
  41. package/src/package_load/package_load_worker.ts +980 -0
  42. package/src/package_load/protocol.ts +336 -0
  43. package/src/path_safety.ts +9 -3
  44. package/src/query_cap_metrics.spec.ts +89 -0
  45. package/src/query_cap_metrics.ts +115 -0
  46. package/src/query_concurrency.spec.ts +247 -0
  47. package/src/query_concurrency.ts +236 -0
  48. package/src/query_param_utils.ts +18 -0
  49. package/src/query_timeout.spec.ts +224 -0
  50. package/src/query_timeout.ts +178 -0
  51. package/src/server-old.ts +21 -1
  52. package/src/server.ts +61 -57
  53. package/src/service/connection.ts +8 -2
  54. package/src/service/db_utils.spec.ts +1 -1
  55. package/src/service/environment.ts +85 -4
  56. package/src/service/environment_admission.spec.ts +165 -1
  57. package/src/service/environment_store.spec.ts +103 -0
  58. package/src/service/environment_store.ts +98 -26
  59. package/src/service/filter_integration.spec.ts +110 -0
  60. package/src/service/given.ts +80 -0
  61. package/src/service/givens_integration.spec.ts +192 -0
  62. package/src/service/model.spec.ts +298 -3
  63. package/src/service/model.ts +362 -23
  64. package/src/service/model_limits.spec.ts +181 -0
  65. package/src/service/model_limits.ts +110 -0
  66. package/src/service/package.spec.ts +12 -6
  67. package/src/service/package.ts +263 -146
  68. package/src/service/package_worker_path.spec.ts +196 -0
  69. package/src/service/path_injection.spec.ts +39 -0
  70. package/src/stream_helpers.spec.ts +280 -0
  71. package/src/stream_helpers.ts +162 -0
  72. package/src/test_helpers/metrics_harness.ts +126 -0
  73. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  74. package/dist/app/assets/HomePage-DwkH7OrS.js +0 -1
  75. package/dist/app/assets/index-U38AyjJL.js +0 -451
@@ -1,8 +1,19 @@
1
1
  import { Connection, RunSQLOptions, TableSourceDef } from "@malloydata/malloy";
2
2
  import { PersistSQLResults } from "@malloydata/malloy/connection";
3
3
  import { components } from "../api";
4
- import { BadRequestError, ConnectionError } from "../errors";
4
+ import {
5
+ getMaxQueryRows,
6
+ getMaxResponseBytes,
7
+ getQueryTimeoutMs,
8
+ } from "../config";
9
+ import {
10
+ BadRequestError,
11
+ ConnectionError,
12
+ PayloadTooLargeError,
13
+ } from "../errors";
14
+ import { recordQueryCapExceeded } from "../query_cap_metrics";
5
15
  import { logger } from "../logger";
16
+ import { runWithQueryTimeout } from "../query_timeout";
6
17
  import { testConnectionConfig } from "../service/connection";
7
18
  import { validateDuckdbApiSurface } from "../service/connection_config";
8
19
  import { ConnectionService } from "../service/connection_service";
@@ -12,6 +23,7 @@ import {
12
23
  } from "../service/db_utils";
13
24
  import type { Environment } from "../service/environment";
14
25
  import { EnvironmentStore } from "../service/environment_store";
26
+ import { isStreamingConnection, streamSqlWithBudget } from "../stream_helpers";
15
27
 
16
28
  type ApiConnection = components["schemas"]["Connection"];
17
29
  type ApiConnectionStatus = components["schemas"]["ConnectionStatus"];
@@ -442,6 +454,28 @@ export class ConnectionController {
442
454
  options: string,
443
455
  packageName?: string,
444
456
  ): Promise<ApiQueryData> {
457
+ // Express parses repeated query parameters (?sqlStatement=a&sqlStatement=b)
458
+ // and array-shaped JSON bodies as `string[]`, not `string`. The route
459
+ // handlers up-cast for TypeScript's benefit, so re-validate here at the
460
+ // controller boundary before forwarding to the connector.
461
+ if (typeof sqlStatement !== "string") {
462
+ throw new BadRequestError("sqlStatement must be a string");
463
+ }
464
+ if (
465
+ options !== undefined &&
466
+ options !== null &&
467
+ typeof options !== "string"
468
+ ) {
469
+ throw new BadRequestError("options must be a string");
470
+ }
471
+
472
+ // Shed load before any disk / DB work; same rationale as
473
+ // QueryController. The env-fetch is cached so this is effectively
474
+ // a single O(1) boolean read on the hot path.
475
+ const environmentForAdmission =
476
+ await this.environmentStore.getEnvironment(environmentName, false);
477
+ environmentForAdmission.assertCanAdmitQuery();
478
+
445
479
  const malloyConnection = await this.getMalloyConnection(
446
480
  environmentName,
447
481
  connectionName,
@@ -450,7 +484,27 @@ export class ConnectionController {
450
484
 
451
485
  let runSQLOptions: RunSQLOptions = {};
452
486
  if (options) {
453
- runSQLOptions = JSON.parse(options) as RunSQLOptions;
487
+ // JSON.parse happily produces `null`, `42`, `"foo"`, `[1,2,3]`, etc.
488
+ // Anything that isn't a plain object would either crash later when
489
+ // we touch `.abortSignal` / `.rowLimit` (null), or mutate a caller
490
+ // string / array and pass it to the connector. Reject at the
491
+ // boundary alongside the other CodeQL type guards above.
492
+ let parsed: unknown;
493
+ try {
494
+ parsed = JSON.parse(options);
495
+ } catch {
496
+ throw new BadRequestError("options must be valid JSON");
497
+ }
498
+ if (
499
+ parsed === null ||
500
+ typeof parsed !== "object" ||
501
+ Array.isArray(parsed)
502
+ ) {
503
+ throw new BadRequestError(
504
+ 'options must be a JSON object (e.g. {"rowLimit": 100})',
505
+ );
506
+ }
507
+ runSQLOptions = parsed as RunSQLOptions;
454
508
  }
455
509
  if (runSQLOptions.abortSignal) {
456
510
  // Add support for abortSignal in the future
@@ -458,15 +512,96 @@ export class ConnectionController {
458
512
  runSQLOptions.abortSignal = undefined;
459
513
  }
460
514
 
461
- try {
462
- return {
463
- data: JSON.stringify(
464
- await malloyConnection.runSQL(sqlStatement, runSQLOptions),
465
- ),
515
+ // Bound the response with two layered caps:
516
+ //
517
+ // - Row cap (PUBLISHER_MAX_QUERY_ROWS) — pushed to the driver as
518
+ // RunSQLOptions.rowLimit = min(callerLimit, cap+1). The +1 is a
519
+ // sentinel: a response of cap+1 rows means the original would have
520
+ // overflowed, so we fail the request with HTTP 413 rather than
521
+ // serializing it. A caller-supplied rowLimit lower than cap+1 is
522
+ // kept; we only enforce the ceiling. Cap of 0 disables the bound.
523
+ //
524
+ // - Byte cap (PUBLISHER_MAX_RESPONSE_BYTES) — the actual memory
525
+ // bound, since a single 10 MB JSON column can blow past the
526
+ // row cap's safe envelope. Only enforceable on streaming
527
+ // connections; row-buffered `runSQL` has already done the
528
+ // damage by the time we'd count bytes.
529
+ //
530
+ // We trust connectors to honor RunSQLOptions.rowLimit; connectors
531
+ // that don't are upstream bugs.
532
+ const maxRows = getMaxQueryRows();
533
+ const maxBytes = getMaxResponseBytes();
534
+ if (maxRows > 0) {
535
+ runSQLOptions.rowLimit = Math.min(
536
+ runSQLOptions.rowLimit ?? maxRows + 1,
537
+ maxRows + 1,
538
+ );
539
+ }
540
+
541
+ // Streaming-capable connections (Postgres, DuckDB, ...) go through
542
+ // streamSqlWithBudget so the byte cap can fire mid-stream. Other
543
+ // connections fall back to the buffered path; client-side byte
544
+ // counting there would be security theatre.
545
+ //
546
+ // The whole driver call is wrapped in runWithQueryTimeout so a
547
+ // hung query is surfaced as HTTP 504 instead of holding the
548
+ // HTTP socket open until the load balancer cuts the connection.
549
+ // The timeout signal is plumbed into RunSQLOptions.abortSignal
550
+ // so the abort actually cancels the underlying network call —
551
+ // not just unblocks the awaiter.
552
+ if (isStreamingConnection(malloyConnection)) {
553
+ const streamed = await runWithQueryTimeout(async (signal) => {
554
+ const optionsWithSignal: RunSQLOptions = {
555
+ ...runSQLOptions,
556
+ abortSignal: signal,
557
+ };
558
+ try {
559
+ return await streamSqlWithBudget(
560
+ malloyConnection,
561
+ sqlStatement,
562
+ optionsWithSignal,
563
+ { maxRows, maxBytes },
564
+ );
565
+ } catch (error) {
566
+ if (error instanceof PayloadTooLargeError) throw error;
567
+ // If runWithQueryTimeout is about to wrap this in a
568
+ // QueryTimeoutError (because the timer fired), the
569
+ // ConnectionError we'd throw here is discarded — the
570
+ // timeout verdict wins. So this branch only matters
571
+ // for genuine driver failures.
572
+ throw new ConnectionError((error as Error).message);
573
+ }
574
+ }, getQueryTimeoutMs());
575
+ return { data: JSON.stringify(streamed) };
576
+ }
577
+
578
+ const result = await runWithQueryTimeout(async (signal) => {
579
+ const optionsWithSignal: RunSQLOptions = {
580
+ ...runSQLOptions,
581
+ abortSignal: signal,
466
582
  };
467
- } catch (error) {
468
- throw new ConnectionError((error as Error).message);
583
+ try {
584
+ return await malloyConnection.runSQL(
585
+ sqlStatement,
586
+ optionsWithSignal,
587
+ );
588
+ } catch (error) {
589
+ throw new ConnectionError((error as Error).message);
590
+ }
591
+ }, getQueryTimeoutMs());
592
+
593
+ if (maxRows > 0 && result.rows.length > maxRows) {
594
+ // Tick the cap-exceeded counter on the buffered path too
595
+ // so `publisher_query_cap_exceeded_total{cap_type="rows",
596
+ // source="connection_sql"}` captures rejections from
597
+ // *all* connectors, not just streaming-capable ones.
598
+ recordQueryCapExceeded("rows", "connection_sql");
599
+ throw new PayloadTooLargeError(
600
+ `Query returned more than ${maxRows} rows. Refine the query (add a LIMIT or more selective WHERE) or raise PUBLISHER_MAX_QUERY_ROWS.`,
601
+ );
469
602
  }
603
+
604
+ return { data: JSON.stringify(result) };
470
605
  }
471
606
 
472
607
  public async getConnectionTemporaryTable(
@@ -475,23 +610,75 @@ export class ConnectionController {
475
610
  sqlStatement: string,
476
611
  packageName?: string,
477
612
  ): Promise<ApiTemporaryTable> {
613
+ // Express parses repeated query parameters / array-shaped JSON
614
+ // bodies as `string[]`. The route handlers up-cast for
615
+ // TypeScript's benefit; re-validate here at the controller
616
+ // boundary before forwarding to the connector — same pattern
617
+ // as `getConnectionQueryData`.
618
+ if (typeof sqlStatement !== "string") {
619
+ throw new BadRequestError("sqlStatement must be a string");
620
+ }
621
+
622
+ // Shed load before any disk / DB work; same rationale as
623
+ // `getConnectionQueryData`. `manifestTemporaryTable` issues a
624
+ // real `CREATE TEMPORARY TABLE AS (<sql>)` against the
625
+ // connector, so a back-pressured publisher must reject these
626
+ // with HTTP 503 just like ad-hoc SELECTs.
627
+ const environmentForAdmission =
628
+ await this.environmentStore.getEnvironment(environmentName, false);
629
+ environmentForAdmission.assertCanAdmitQuery();
630
+
478
631
  const malloyConnection = await this.getMalloyConnection(
479
632
  environmentName,
480
633
  connectionName,
481
634
  packageName,
482
635
  );
483
636
 
484
- try {
485
- return {
486
- table: JSON.stringify(
487
- await (
488
- malloyConnection as PersistSQLResults
489
- ).manifestTemporaryTable(sqlStatement),
490
- ),
491
- };
492
- } catch (error) {
493
- throw new ConnectionError((error as Error).message);
494
- }
637
+ // `manifestTemporaryTable(sqlCommand: string): Promise<string>`
638
+ // does not accept an `abortSignal`, so we cannot push
639
+ // cancellation down to the driver. Instead we race the call
640
+ // against the publisher's timeout signal so the HTTP response
641
+ // unblocks at the wall-clock deadline regardless of whether
642
+ // the underlying DDL responds. The DDL continues running in
643
+ // the database — the publisher just stops waiting and
644
+ // returns HTTP 504. This is strictly better than holding the
645
+ // socket and concurrency slot open until the load balancer
646
+ // kills the connection, but it does mean a runaway DDL is
647
+ // not actually cancelled (driver-side enhancement needed in
648
+ // `@malloydata/db-*` to plumb abort through).
649
+ //
650
+ // The `runWithQueryTimeout` wrapper's catch handler checks its
651
+ // `timedOut` flag at error time, so any rejection that
652
+ // propagates after the timer has fired is re-emitted as
653
+ // `QueryTimeoutError` regardless of its shape — that's why
654
+ // we re-throw the abort reason here unwrapped.
655
+ return await runWithQueryTimeout(async (signal) => {
656
+ try {
657
+ const tableName = await Promise.race([
658
+ (malloyConnection as PersistSQLResults).manifestTemporaryTable(
659
+ sqlStatement,
660
+ ),
661
+ new Promise<never>((_resolve, reject) => {
662
+ if (signal.aborted) {
663
+ reject(signal.reason);
664
+ return;
665
+ }
666
+ signal.addEventListener(
667
+ "abort",
668
+ () => reject(signal.reason),
669
+ { once: true },
670
+ );
671
+ }),
672
+ ]);
673
+ return { table: JSON.stringify(tableName) };
674
+ } catch (error) {
675
+ // If the abort fired first, the timeout wrapper above
676
+ // will convert this to QueryTimeoutError on its own
677
+ // — don't bury the reason in ConnectionError.
678
+ if (signal.aborted) throw error;
679
+ throw new ConnectionError((error as Error).message);
680
+ }
681
+ }, getQueryTimeoutMs());
495
682
  }
496
683
 
497
684
  public async testConnectionConfiguration(
@@ -1,7 +1,10 @@
1
1
  import { components } from "../api";
2
+ import { getQueryTimeoutMs } from "../config";
2
3
  import { ModelNotFoundError } from "../errors";
4
+ import { runWithQueryTimeout } from "../query_timeout";
3
5
  import { EnvironmentStore } from "../service/environment_store";
4
6
  import type { FilterParams } from "../service/filter";
7
+ import type { GivenValue } from "@malloydata/malloy";
5
8
 
6
9
  type ApiNotebook = components["schemas"]["Notebook"];
7
10
  type ApiModel = components["schemas"]["Model"];
@@ -97,6 +100,7 @@ export class ModelController {
97
100
  cellIndex: number,
98
101
  filterParams?: FilterParams,
99
102
  bypassFilters?: boolean,
103
+ givens?: Record<string, GivenValue>,
100
104
  ): Promise<{
101
105
  type: "code" | "markdown";
102
106
  text: string;
@@ -108,6 +112,10 @@ export class ModelController {
108
112
  environmentName,
109
113
  false,
110
114
  );
115
+ // Shed load before any disk / DB work; same rationale as
116
+ // QueryController.getQuery — already-loaded packages bypass the
117
+ // package-load admission gate.
118
+ environment.assertCanAdmitQuery();
111
119
  const p = await environment.getPackage(packageName, false);
112
120
  const model = p.getModel(notebookPath);
113
121
  if (!model) {
@@ -117,6 +125,16 @@ export class ModelController {
117
125
  throw new ModelNotFoundError(`${notebookPath} is a model`);
118
126
  }
119
127
 
120
- return model.executeNotebookCell(cellIndex, filterParams, bypassFilters);
128
+ return runWithQueryTimeout(
129
+ (abortSignal) =>
130
+ model.executeNotebookCell(
131
+ cellIndex,
132
+ filterParams,
133
+ bypassFilters,
134
+ givens,
135
+ abortSignal,
136
+ ),
137
+ getQueryTimeoutMs(),
138
+ );
121
139
  }
122
140
  }
@@ -1,9 +1,12 @@
1
1
  import { validateRenderTags } from "@malloydata/render-validator";
2
2
  import { components } from "../api";
3
+ import { getQueryTimeoutMs } from "../config";
3
4
  import { API_PREFIX } from "../constants";
4
5
  import { ModelNotFoundError } from "../errors";
6
+ import { runWithQueryTimeout } from "../query_timeout";
5
7
  import { EnvironmentStore } from "../service/environment_store";
6
8
  import type { FilterParams } from "../service/filter";
9
+ import type { GivenValue } from "@malloydata/malloy";
7
10
 
8
11
  type ApiQuery = components["schemas"]["QueryResult"];
9
12
 
@@ -32,23 +35,36 @@ export class QueryController {
32
35
  compactJson: boolean = false,
33
36
  filterParams?: FilterParams,
34
37
  bypassFilters?: boolean,
38
+ givens?: Record<string, GivenValue>,
35
39
  ): Promise<ApiQuery> {
36
40
  const environment = await this.environmentStore.getEnvironment(
37
41
  environmentName,
38
42
  false,
39
43
  );
44
+ // Shed load before any disk / DB work so the publisher returns 503
45
+ // immediately under memory pressure instead of starting a query and
46
+ // crashing partway through. Already-loaded packages bypass the
47
+ // package-load admission gate, so this is the only thing protecting
48
+ // query traffic on a hot pod.
49
+ environment.assertCanAdmitQuery();
40
50
  const p = await environment.getPackage(packageName, false);
41
51
  const model = p.getModel(modelPath);
42
52
 
43
53
  if (!model) {
44
54
  throw new ModelNotFoundError(`${modelPath} does not exist`);
45
55
  } else {
46
- const { result, compactResult } = await model.getQueryResults(
47
- sourceName,
48
- queryName,
49
- query,
50
- filterParams,
51
- bypassFilters,
56
+ const { result, compactResult } = await runWithQueryTimeout(
57
+ (abortSignal) =>
58
+ model.getQueryResults(
59
+ sourceName,
60
+ queryName,
61
+ query,
62
+ filterParams,
63
+ bypassFilters,
64
+ givens,
65
+ abortSignal,
66
+ ),
67
+ getQueryTimeoutMs(),
52
68
  );
53
69
  const renderLogs = validateRenderTags(result);
54
70
  return {
@@ -1,8 +1,9 @@
1
1
  import chokidar, { FSWatcher } from "chokidar";
2
2
  import { RequestHandler } from "express";
3
- import path from "path";
4
3
  import { components } from "../api";
4
+ import { internalErrorToHttpError } from "../errors";
5
5
  import { logger } from "../logger";
6
+ import { assertSafePackageName, safeJoinUnderRoot } from "../path_safety";
6
7
  import { EnvironmentStore } from "../service/environment_store";
7
8
 
8
9
  type StartWatchReq = components["schemas"]["StartWatchRequest"];
@@ -32,6 +33,14 @@ export class WatchModeController {
32
33
  res,
33
34
  ) => {
34
35
  const watchName = req.body.environmentName ?? "";
36
+ try {
37
+ assertSafePackageName(watchName);
38
+ } catch (error) {
39
+ logger.error(error);
40
+ const { status } = internalErrorToHttpError(error as Error);
41
+ res.status(status).json({ error: (error as Error).message });
42
+ return;
43
+ }
35
44
  const environmentManifest =
36
45
  await EnvironmentStore.reloadEnvironmentManifest(
37
46
  this.environmentStore.serverRootPath,
@@ -53,7 +62,7 @@ export class WatchModeController {
53
62
  return;
54
63
  }
55
64
 
56
- this.watchingPath = path.join(
65
+ this.watchingPath = safeJoinUnderRoot(
57
66
  this.environmentStore.serverRootPath,
58
67
  watchName,
59
68
  );
@@ -4,6 +4,9 @@ import {
4
4
  ConnectionAuthError,
5
5
  ConnectionError,
6
6
  internalErrorToHttpError,
7
+ PayloadTooLargeError,
8
+ QueryTimeoutError,
9
+ ServiceUnavailableError,
7
10
  } from "./errors";
8
11
 
9
12
  describe("internalErrorToHttpError", () => {
@@ -39,4 +42,45 @@ describe("internalErrorToHttpError", () => {
39
42
  expect(status).toBe(500);
40
43
  expect(json.message).toBe("boom");
41
44
  });
45
+
46
+ it("maps PayloadTooLargeError to 413", () => {
47
+ const { status, json } = internalErrorToHttpError(
48
+ new PayloadTooLargeError(
49
+ "Query returned more than 100000 rows; refine the query or raise PUBLISHER_MAX_QUERY_ROWS.",
50
+ ),
51
+ );
52
+ expect(status).toBe(413);
53
+ expect(json).toEqual({
54
+ code: 413,
55
+ message:
56
+ "Query returned more than 100000 rows; refine the query or raise PUBLISHER_MAX_QUERY_ROWS.",
57
+ });
58
+ });
59
+
60
+ it("maps ServiceUnavailableError to 503 (load shedding / back-pressure)", () => {
61
+ const { status, json } = internalErrorToHttpError(
62
+ new ServiceUnavailableError(
63
+ "Pod at max concurrent queries (32); retry later.",
64
+ ),
65
+ );
66
+ expect(status).toBe(503);
67
+ expect(json).toEqual({
68
+ code: 503,
69
+ message: "Pod at max concurrent queries (32); retry later.",
70
+ });
71
+ });
72
+
73
+ it("maps QueryTimeoutError to 504 (gateway timeout, distinct from 503 back-pressure)", () => {
74
+ const { status, json } = internalErrorToHttpError(
75
+ new QueryTimeoutError(
76
+ "Query exceeded PUBLISHER_QUERY_TIMEOUT_MS (300000ms) and was aborted.",
77
+ ),
78
+ );
79
+ expect(status).toBe(504);
80
+ expect(json).toEqual({
81
+ code: 504,
82
+ message:
83
+ "Query exceeded PUBLISHER_QUERY_TIMEOUT_MS (300000ms) and was aborted.",
84
+ });
85
+ });
42
86
  });
package/src/errors.ts CHANGED
@@ -30,6 +30,10 @@ export function internalErrorToHttpError(error: Error) {
30
30
  return httpError(409, error.message);
31
31
  } else if (error instanceof ServiceUnavailableError) {
32
32
  return httpError(503, error.message);
33
+ } else if (error instanceof PayloadTooLargeError) {
34
+ return httpError(413, error.message);
35
+ } else if (error instanceof QueryTimeoutError) {
36
+ return httpError(504, error.message);
33
37
  } else {
34
38
  return httpError(500, error.message);
35
39
  }
@@ -135,3 +139,33 @@ export class ServiceUnavailableError extends Error {
135
139
  super(message);
136
140
  }
137
141
  }
142
+
143
+ /**
144
+ * Thrown when a response would exceed a server-side size cap (e.g. an
145
+ * ad-hoc connection SQL query that returned more than
146
+ * `PUBLISHER_MAX_QUERY_ROWS` rows). Mapped to HTTP 413 so callers know
147
+ * the request was well-formed but the result is too large for the
148
+ * publisher to materialize; the remediation is "refine the query" or
149
+ * "raise the cap", not "retry".
150
+ */
151
+ export class PayloadTooLargeError extends Error {
152
+ constructor(message: string) {
153
+ super(message);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Thrown when a query exceeded the configured wall-clock budget
159
+ * (`PUBLISHER_QUERY_TIMEOUT_MS`) and the publisher aborted it
160
+ * mid-execution. Mapped to HTTP 504 (`Gateway Timeout`) because the
161
+ * publisher acts as a gateway to the underlying database — the
162
+ * upstream caller did nothing wrong, but the downstream query took
163
+ * too long. Distinct from {@link ServiceUnavailableError} so clients
164
+ * can distinguish "back off, the pod is loaded" (503, retryable)
165
+ * from "this specific query is too expensive" (504, refine it).
166
+ */
167
+ export class QueryTimeoutError extends Error {
168
+ constructor(message: string) {
169
+ super(message);
170
+ }
171
+ }
@@ -0,0 +1,90 @@
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
+ import { Server } from "http";
3
+ import { performGracefulShutdownAfterDrain } from "./health";
4
+ import { logger } from "./logger";
5
+
6
+ // Regression test for the graceful-shutdown ordering bug that caused
7
+ // [winston] Attempt to write logs with no transports: {"message":"Waiting 50 seconds..."}
8
+ // to appear in production logs. logger.close() must run after every
9
+ // logger.* call, including the "Waiting ... seconds after server close
10
+ // before exit..." message.
11
+ //
12
+ // Tests call performGracefulShutdownAfterDrain directly rather than
13
+ // emitting SIGTERM, so module-level operationalState is not mutated
14
+ // and the spec stays isolated from sibling tests in the same process.
15
+ describe("performGracefulShutdownAfterDrain: shutdown ordering", () => {
16
+ const originalExit = process.exit;
17
+ let callOrder: string[];
18
+
19
+ beforeEach(() => {
20
+ callOrder = [];
21
+
22
+ spyOn(logger, "info").mockImplementation(((msg: string) => {
23
+ callOrder.push(`info:${msg}`);
24
+ return logger;
25
+ }) as never);
26
+ spyOn(logger, "close").mockImplementation((() => {
27
+ callOrder.push("close");
28
+ return logger;
29
+ }) as never);
30
+ // Silence warn/error calls so spec output stays clean. They are
31
+ // not load-bearing for these assertions.
32
+ spyOn(logger, "warn").mockImplementation((() => logger) as never);
33
+ spyOn(logger, "error").mockImplementation((() => logger) as never);
34
+
35
+ process.exit = ((_code?: number) => {
36
+ callOrder.push("exit");
37
+ }) as never;
38
+ });
39
+
40
+ afterEach(() => {
41
+ process.exit = originalExit;
42
+ });
43
+
44
+ const fakeServer = (): Server => ({ listening: false }) as unknown as Server;
45
+
46
+ it("logs the 'Waiting ...' message before closing the logger", async () => {
47
+ await performGracefulShutdownAfterDrain(fakeServer(), fakeServer(), 0.05);
48
+
49
+ const waitingIdx = callOrder.findIndex((entry) =>
50
+ entry.startsWith("info:Waiting"),
51
+ );
52
+ const closeIdx = callOrder.indexOf("close");
53
+ const exitIdx = callOrder.indexOf("exit");
54
+
55
+ expect(waitingIdx).toBeGreaterThanOrEqual(0);
56
+ expect(closeIdx).toBeGreaterThanOrEqual(0);
57
+ expect(exitIdx).toBeGreaterThanOrEqual(0);
58
+ expect(waitingIdx).toBeLessThan(closeIdx);
59
+ expect(closeIdx).toBeLessThan(exitIdx);
60
+ });
61
+
62
+ it("emits no logger.info calls after logger.close", async () => {
63
+ await performGracefulShutdownAfterDrain(fakeServer(), fakeServer(), 0.05);
64
+
65
+ const closeIdx = callOrder.indexOf("close");
66
+ const lateInfoIdx = callOrder.findIndex(
67
+ (entry, idx) => idx > closeIdx && entry.startsWith("info:"),
68
+ );
69
+ expect(closeIdx).toBeGreaterThanOrEqual(0);
70
+ expect(lateInfoIdx).toBe(-1);
71
+ });
72
+
73
+ it("closes the logger exactly once", async () => {
74
+ await performGracefulShutdownAfterDrain(fakeServer(), fakeServer(), 0.05);
75
+
76
+ const closes = callOrder.filter((entry) => entry === "close").length;
77
+ expect(closes).toBe(1);
78
+ });
79
+
80
+ it("skips the 'Waiting ...' message when gracefulCloseTimeoutSeconds is 0", async () => {
81
+ await performGracefulShutdownAfterDrain(fakeServer(), fakeServer(), 0);
82
+
83
+ const waitingCalls = callOrder.filter((entry) =>
84
+ entry.startsWith("info:Waiting"),
85
+ );
86
+ expect(waitingCalls.length).toBe(0);
87
+ expect(callOrder.indexOf("close")).toBeGreaterThanOrEqual(0);
88
+ expect(callOrder.indexOf("exit")).toBeGreaterThanOrEqual(0);
89
+ });
90
+ });