@malloy-publisher/server 0.0.199 → 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.
- package/dist/app/api-doc.yaml +76 -111
- package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-CgKNjySu.js} +1 -1
- package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
- package/dist/app/assets/{MainPage-DsVt5QGM.js → MainPage-CAwb8U82.js} +2 -2
- package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-C0Uevsw9.js} +1 -1
- package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-Cu-u9k1g.js} +1 -1
- package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-DVwPh2Ql.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-DW38R2Zv.js} +1 -1
- package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
- package/dist/app/assets/{index-D1pdwrUW.js → index-BGdcKsFF.js} +1 -1
- package/dist/app/assets/{index-BUp81Qdm.js → index-CTx4v4_3.js} +1 -1
- package/dist/app/assets/index-DE6d5jEy.js +452 -0
- package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-C1Mi1uRm.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/server.mjs +1482 -1010
- package/package.json +1 -1
- package/src/config.spec.ts +246 -0
- package/src/config.ts +121 -1
- package/src/constants.ts +84 -1
- package/src/controller/connection.controller.spec.ts +803 -0
- package/src/controller/connection.controller.ts +207 -20
- package/src/controller/model.controller.ts +16 -5
- package/src/controller/query.controller.ts +20 -7
- package/src/controller/watch-mode.controller.ts +11 -2
- package/src/errors.spec.ts +44 -0
- package/src/errors.ts +34 -0
- package/src/heap_check.spec.ts +144 -0
- package/src/heap_check.ts +144 -0
- package/src/mcp/handler_utils.ts +14 -0
- package/src/mcp/tools/execute_query_tool.ts +44 -14
- package/src/oom_guards.integration.spec.ts +261 -0
- package/src/path_safety.ts +9 -3
- package/src/query_cap_metrics.spec.ts +89 -0
- package/src/query_cap_metrics.ts +115 -0
- package/src/query_concurrency.spec.ts +247 -0
- package/src/query_concurrency.ts +236 -0
- package/src/query_timeout.spec.ts +224 -0
- package/src/query_timeout.ts +178 -0
- package/src/server-old.ts +20 -0
- package/src/server.ts +25 -47
- package/src/service/connection.ts +8 -2
- package/src/service/environment.ts +82 -2
- package/src/service/environment_admission.spec.ts +165 -1
- package/src/service/environment_store.spec.ts +103 -0
- package/src/service/environment_store.ts +74 -23
- package/src/service/model.spec.ts +193 -3
- package/src/service/model.ts +80 -12
- package/src/service/model_limits.spec.ts +181 -0
- package/src/service/model_limits.ts +110 -0
- package/src/service/package.spec.ts +2 -6
- package/src/service/package.ts +6 -1
- package/src/service/path_injection.spec.ts +39 -0
- package/src/stream_helpers.spec.ts +280 -0
- package/src/stream_helpers.ts +162 -0
- package/src/test_helpers/metrics_harness.ts +126 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +0 -1
- package/dist/app/assets/index-Dv5bF4Ii.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 {
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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,5 +1,7 @@
|
|
|
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";
|
|
5
7
|
import type { GivenValue } from "@malloydata/malloy";
|
|
@@ -110,6 +112,10 @@ export class ModelController {
|
|
|
110
112
|
environmentName,
|
|
111
113
|
false,
|
|
112
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();
|
|
113
119
|
const p = await environment.getPackage(packageName, false);
|
|
114
120
|
const model = p.getModel(notebookPath);
|
|
115
121
|
if (!model) {
|
|
@@ -119,11 +125,16 @@ export class ModelController {
|
|
|
119
125
|
throw new ModelNotFoundError(`${notebookPath} is a model`);
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
return runWithQueryTimeout(
|
|
129
|
+
(abortSignal) =>
|
|
130
|
+
model.executeNotebookCell(
|
|
131
|
+
cellIndex,
|
|
132
|
+
filterParams,
|
|
133
|
+
bypassFilters,
|
|
134
|
+
givens,
|
|
135
|
+
abortSignal,
|
|
136
|
+
),
|
|
137
|
+
getQueryTimeoutMs(),
|
|
127
138
|
);
|
|
128
139
|
}
|
|
129
140
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
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";
|
|
7
9
|
import type { GivenValue } from "@malloydata/malloy";
|
|
@@ -39,19 +41,30 @@ export class QueryController {
|
|
|
39
41
|
environmentName,
|
|
40
42
|
false,
|
|
41
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();
|
|
42
50
|
const p = await environment.getPackage(packageName, false);
|
|
43
51
|
const model = p.getModel(modelPath);
|
|
44
52
|
|
|
45
53
|
if (!model) {
|
|
46
54
|
throw new ModelNotFoundError(`${modelPath} does not exist`);
|
|
47
55
|
} else {
|
|
48
|
-
const { result, compactResult } = await
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(),
|
|
55
68
|
);
|
|
56
69
|
const renderLogs = validateRenderTags(result);
|
|
57
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 =
|
|
65
|
+
this.watchingPath = safeJoinUnderRoot(
|
|
57
66
|
this.environmentStore.serverRootPath,
|
|
58
67
|
watchName,
|
|
59
68
|
);
|
package/src/errors.spec.ts
CHANGED
|
@@ -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,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkHeapConfiguration,
|
|
5
|
+
resetHeapTelemetryForTesting,
|
|
6
|
+
} from "./heap_check";
|
|
7
|
+
import {
|
|
8
|
+
startMetricsHarness,
|
|
9
|
+
type MetricsHarness,
|
|
10
|
+
} from "./test_helpers/metrics_harness";
|
|
11
|
+
|
|
12
|
+
function makeLogStub(): {
|
|
13
|
+
warn: ReturnType<typeof mock>;
|
|
14
|
+
info: ReturnType<typeof mock>;
|
|
15
|
+
} {
|
|
16
|
+
return {
|
|
17
|
+
warn: mock(() => undefined),
|
|
18
|
+
info: mock(() => undefined),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("checkHeapConfiguration", () => {
|
|
23
|
+
it("warns + reports warned=true when heap limit is below the 2 GiB threshold", () => {
|
|
24
|
+
const log = makeLogStub();
|
|
25
|
+
// 1.5 GiB — below the recommended floor.
|
|
26
|
+
const { warned } = checkHeapConfiguration({
|
|
27
|
+
getHeapStatistics: () => ({
|
|
28
|
+
heap_size_limit: 1.5 * 1024 * 1024 * 1024,
|
|
29
|
+
}),
|
|
30
|
+
log,
|
|
31
|
+
});
|
|
32
|
+
expect(warned).toBe(true);
|
|
33
|
+
expect(log.warn).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(log.info).not.toHaveBeenCalled();
|
|
35
|
+
const args = log.warn.mock.calls[0] as unknown as [
|
|
36
|
+
string,
|
|
37
|
+
Record<string, unknown>,
|
|
38
|
+
];
|
|
39
|
+
// Operator must be able to grep for the offending env-var
|
|
40
|
+
// hint without guessing the surrounding sentence.
|
|
41
|
+
expect(args[0]).toContain("--max-old-space-size");
|
|
42
|
+
expect(args[0]).toContain("MiB");
|
|
43
|
+
expect(args[1].heapSizeLimitBytes).toBe(1.5 * 1024 * 1024 * 1024);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("logs at info + reports warned=false when heap limit meets the recommendation", () => {
|
|
47
|
+
const log = makeLogStub();
|
|
48
|
+
const { warned, heapSizeLimitBytes } = checkHeapConfiguration({
|
|
49
|
+
getHeapStatistics: () => ({
|
|
50
|
+
heap_size_limit: 4 * 1024 * 1024 * 1024,
|
|
51
|
+
}),
|
|
52
|
+
log,
|
|
53
|
+
});
|
|
54
|
+
expect(warned).toBe(false);
|
|
55
|
+
expect(heapSizeLimitBytes).toBe(4 * 1024 * 1024 * 1024);
|
|
56
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
57
|
+
expect(log.info).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("treats exactly-the-recommended-value as 'meets threshold' (no warn)", () => {
|
|
61
|
+
// Boundary: the comparison is `<`, so equality should pass.
|
|
62
|
+
// Lock the boundary so a future tightening to `<=` requires
|
|
63
|
+
// an explicit test change rather than silently regressing.
|
|
64
|
+
const log = makeLogStub();
|
|
65
|
+
const { warned } = checkHeapConfiguration({
|
|
66
|
+
getHeapStatistics: () => ({
|
|
67
|
+
heap_size_limit: 2 * 1024 * 1024 * 1024,
|
|
68
|
+
}),
|
|
69
|
+
log,
|
|
70
|
+
});
|
|
71
|
+
expect(warned).toBe(false);
|
|
72
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not throw or warn under tiny heaps; the cap helpers still work", () => {
|
|
76
|
+
// Pathological "this pod has 128 MiB" case: we want a noisy
|
|
77
|
+
// warning, not a process crash, so the publisher still boots
|
|
78
|
+
// and the operator sees the message in pod logs.
|
|
79
|
+
const log = makeLogStub();
|
|
80
|
+
expect(() =>
|
|
81
|
+
checkHeapConfiguration({
|
|
82
|
+
getHeapStatistics: () => ({ heap_size_limit: 128 * 1024 * 1024 }),
|
|
83
|
+
log,
|
|
84
|
+
}),
|
|
85
|
+
).not.toThrow();
|
|
86
|
+
expect(log.warn).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("uses live v8.getHeapStatistics when no override is provided (smoke check)", () => {
|
|
90
|
+
// No assertion on warn/info — the result depends on how Node
|
|
91
|
+
// was started — just that the call resolves and returns a
|
|
92
|
+
// sensible structure. Locks the production code path against
|
|
93
|
+
// accidental coupling to the injected getter.
|
|
94
|
+
const result = checkHeapConfiguration();
|
|
95
|
+
expect(result.heapSizeLimitBytes).toBeGreaterThan(0);
|
|
96
|
+
expect(typeof result.warned).toBe("boolean");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("telemetry", () => {
|
|
100
|
+
let harness: MetricsHarness;
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
harness = await startMetricsHarness();
|
|
104
|
+
resetHeapTelemetryForTesting();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(async () => {
|
|
108
|
+
resetHeapTelemetryForTesting();
|
|
109
|
+
await harness.shutdown();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("publisher_heap_size_limit_bytes reports the live V8 heap_size_limit", async () => {
|
|
113
|
+
const log = makeLogStub();
|
|
114
|
+
checkHeapConfiguration({ log });
|
|
115
|
+
const value = await harness.collectGauge(
|
|
116
|
+
"publisher_heap_size_limit_bytes",
|
|
117
|
+
);
|
|
118
|
+
// Live value — we just assert it's a sensible positive
|
|
119
|
+
// number so the test isn't sensitive to how this Bun
|
|
120
|
+
// process was launched.
|
|
121
|
+
expect(typeof value).toBe("number");
|
|
122
|
+
expect(value).toBeGreaterThan(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("publisher_heap_used_bytes reports the live V8 used_heap_size", async () => {
|
|
126
|
+
const log = makeLogStub();
|
|
127
|
+
checkHeapConfiguration({ log });
|
|
128
|
+
const value = await harness.collectGauge("publisher_heap_used_bytes");
|
|
129
|
+
expect(typeof value).toBe("number");
|
|
130
|
+
expect(value).toBeGreaterThan(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not register the gauges before checkHeapConfiguration is called (lazy install)", async () => {
|
|
134
|
+
// The gauges are wired up via `installHeapGauges`, which
|
|
135
|
+
// is intentionally called from `checkHeapConfiguration`
|
|
136
|
+
// so the OTel SDK is fully up before instruments are
|
|
137
|
+
// registered. Without that, instruments would bind to
|
|
138
|
+
// NoOp during module load and never emit data.
|
|
139
|
+
expect(
|
|
140
|
+
await harness.collectGauge("publisher_heap_size_limit_bytes"),
|
|
141
|
+
).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|