chore: checkpoint before performance tuning
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unidesk/provider-gateway",
|
||||
"version": "0.2.9",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -66,6 +66,12 @@ let stopping = false;
|
||||
let upgradeSleepUntil = 0;
|
||||
let upgradeSleepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
interface MicroserviceHttpCacheEntry {
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
result: JsonValue;
|
||||
}
|
||||
|
||||
interface HostSshSession {
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
openedAt: number;
|
||||
@@ -78,7 +84,10 @@ interface HostSshStdin {
|
||||
}
|
||||
|
||||
const hostSshSessions = new Map<string, HostSshSession>();
|
||||
const microserviceHttpCache = new Map<string, MicroserviceHttpCacheEntry>();
|
||||
const microserviceHttpInFlight = new Map<string, Promise<JsonValue>>();
|
||||
const gatewayMetadata = readGatewayMetadata();
|
||||
const microserviceHttpMaxBodyTextLength = 8 * 1024 * 1024;
|
||||
|
||||
function readGatewayMetadataFile(path: string): { name: string; version: string } | null {
|
||||
try {
|
||||
@@ -201,6 +210,7 @@ function currentLabels(): ProviderLabels {
|
||||
providerGatewayVersion: gatewayMetadata.version,
|
||||
providerGatewayStartedAt: startedAt.toISOString(),
|
||||
providerGatewayUpgradePolicy: "always-enabled",
|
||||
providerGatewayMicroserviceHttpCache: true,
|
||||
gatewayUptimeSeconds: Math.floor((Date.now() - startedAt.getTime()) / 1000),
|
||||
};
|
||||
}
|
||||
@@ -211,7 +221,7 @@ function sendJson(value: unknown): void {
|
||||
}
|
||||
|
||||
function sendRegister(): void {
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "echo"];
|
||||
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "microservice.http.cache", "echo"];
|
||||
if (isHostSshConfigured()) capabilities.push("host.ssh");
|
||||
sendJson({
|
||||
type: "register",
|
||||
@@ -1334,6 +1344,13 @@ function payloadNumber(payload: Record<string, JsonValue>, key: string, fallback
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function payloadBoundedNumber(payload: Record<string, JsonValue>, key: string, fallback: number, min: number, max: number): number {
|
||||
const raw = payload[key];
|
||||
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.floor(value)));
|
||||
}
|
||||
|
||||
function payloadJsonArrayLimits(payload: Record<string, JsonValue>): Record<string, number> {
|
||||
const raw = payload.jsonArrayLimits;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
||||
@@ -1391,6 +1408,92 @@ function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Rec
|
||||
}
|
||||
}
|
||||
|
||||
function contentTypeIsJson(contentType: string): boolean {
|
||||
return contentType.toLowerCase().includes("json");
|
||||
}
|
||||
|
||||
function boundedMicroserviceBodyText(
|
||||
bodyText: string,
|
||||
contentType: string,
|
||||
metadata: { serviceId: string; path: string; status: number; upstreamBodyBytes: number },
|
||||
): { bodyText: string; truncated: boolean } {
|
||||
if (bodyText.length <= microserviceHttpMaxBodyTextLength) {
|
||||
return { bodyText, truncated: false };
|
||||
}
|
||||
if (contentTypeIsJson(contentType)) {
|
||||
return {
|
||||
bodyText: JSON.stringify({
|
||||
ok: false,
|
||||
error: "microservice proxy response body is too large",
|
||||
serviceId: metadata.serviceId,
|
||||
path: metadata.path,
|
||||
upstreamStatus: metadata.status,
|
||||
upstreamBodyBytes: metadata.upstreamBodyBytes,
|
||||
transformedBodyBytes: bodyText.length,
|
||||
responseBodyLimitBytes: microserviceHttpMaxBodyTextLength,
|
||||
hint: "Use a paged endpoint or tighten __unideskArrayLimit so the response stays below the proxy safety limit.",
|
||||
}),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
return { bodyText: truncateText(bodyText, microserviceHttpMaxBodyTextLength), truncated: true };
|
||||
}
|
||||
|
||||
function microserviceHttpCacheKey(serviceId: string, targetBaseUrl: string, method: string, path: string, query: string, jsonArrayLimits: Record<string, number>): string {
|
||||
return JSON.stringify([serviceId, targetBaseUrl, method, path, query, jsonArrayLimits]);
|
||||
}
|
||||
|
||||
function microserviceHttpCachePrefix(serviceId: string, targetBaseUrl: string): string {
|
||||
return JSON.stringify([serviceId, targetBaseUrl]).slice(0, -1);
|
||||
}
|
||||
|
||||
function cloneJsonObject(value: JsonValue): Record<string, JsonValue> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? { ...value as Record<string, JsonValue> } : { value };
|
||||
}
|
||||
|
||||
function resultWithCacheMetadata(result: JsonValue, metadata: Record<string, JsonValue>): JsonValue {
|
||||
const record = cloneJsonObject(result);
|
||||
record.cache = metadata;
|
||||
return record;
|
||||
}
|
||||
|
||||
function readMicroserviceHttpCache(key: string): JsonValue | null {
|
||||
const entry = microserviceHttpCache.get(key);
|
||||
if (entry === undefined) return null;
|
||||
const now = Date.now();
|
||||
if (entry.expiresAt <= now) {
|
||||
microserviceHttpCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return resultWithCacheMetadata(entry.result, {
|
||||
hit: true,
|
||||
ageMs: now - entry.createdAt,
|
||||
ttlRemainingMs: entry.expiresAt - now,
|
||||
layer: "provider-gateway",
|
||||
});
|
||||
}
|
||||
|
||||
function rememberMicroserviceHttpCache(key: string, ttlMs: number, result: JsonValue): void {
|
||||
if (ttlMs <= 0 || !result || typeof result !== "object" || Array.isArray(result)) return;
|
||||
const record = result as Record<string, JsonValue>;
|
||||
if (record.ok !== true || record.upstreamOk !== true) return;
|
||||
if (record.truncated === true) return;
|
||||
const now = Date.now();
|
||||
microserviceHttpCache.set(key, { createdAt: now, expiresAt: now + ttlMs, result: { ...record } });
|
||||
if (microserviceHttpCache.size > 300) {
|
||||
for (const [cacheKey, entry] of microserviceHttpCache) {
|
||||
if (entry.expiresAt <= now || microserviceHttpCache.size > 240) microserviceHttpCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateMicroserviceHttpCache(serviceId: string, targetBaseUrl: string): void {
|
||||
const prefix = microserviceHttpCachePrefix(serviceId, targetBaseUrl);
|
||||
for (const key of microserviceHttpCache.keys()) {
|
||||
if (key.startsWith(prefix)) microserviceHttpCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||
const rawMethod = String(payload.method || "GET").toUpperCase();
|
||||
const allowedMethods = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
@@ -1407,15 +1510,35 @@ async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<
|
||||
const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000)));
|
||||
const jsonArrayLimits = payloadJsonArrayLimits(payload);
|
||||
const bodyText = payloadString(payload, "bodyText") ?? "";
|
||||
const serviceId = payloadString(payload, "serviceId") ?? "unknown";
|
||||
const cacheTtlMs = payloadBoundedNumber(payload, "cacheTtlMs", 0, 0, 60_000);
|
||||
const cacheable = cacheTtlMs > 0 && (method === "GET" || method === "HEAD") && bodyText.length === 0;
|
||||
const cacheKey = cacheable ? microserviceHttpCacheKey(serviceId, targetBaseUrl, method, path, query, jsonArrayLimits) : "";
|
||||
if (cacheable) {
|
||||
const cached = readMicroserviceHttpCache(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
const inFlight = microserviceHttpInFlight.get(cacheKey);
|
||||
if (inFlight !== undefined) {
|
||||
const result = await inFlight;
|
||||
return resultWithCacheMetadata(result, {
|
||||
hit: true,
|
||||
deduped: true,
|
||||
layer: "provider-gateway",
|
||||
});
|
||||
}
|
||||
} else if (method !== "GET" && method !== "HEAD") {
|
||||
invalidateMicroserviceHttpCache(serviceId, targetBaseUrl);
|
||||
}
|
||||
const requestHeaders = typeof payload.requestHeaders === "object" && payload.requestHeaders !== null && !Array.isArray(payload.requestHeaders)
|
||||
? payload.requestHeaders as Record<string, JsonValue>
|
||||
: {};
|
||||
const headers = new Headers();
|
||||
const contentType = typeof requestHeaders["content-type"] === "string" ? requestHeaders["content-type"] : "";
|
||||
if (contentType.length > 0) headers.set("content-type", contentType);
|
||||
const requestStartedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const requestPromise = (async (): Promise<JsonValue> => {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
@@ -1425,39 +1548,56 @@ async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<
|
||||
const rawBodyText = await response.text();
|
||||
const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
|
||||
const transformed = applyJsonArrayLimits(rawBodyText, contentType, jsonArrayLimits);
|
||||
const bounded = boundedMicroserviceBodyText(transformed.bodyText, contentType, {
|
||||
serviceId,
|
||||
path,
|
||||
status: response.status,
|
||||
upstreamBodyBytes: rawBodyText.length,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
serviceId: payloadString(payload, "serviceId") ?? "unknown",
|
||||
serviceId,
|
||||
method,
|
||||
url: url.toString(),
|
||||
status: response.status,
|
||||
upstreamOk: response.ok,
|
||||
contentType,
|
||||
bodyText: truncateText(transformed.bodyText, 1024 * 1024),
|
||||
bodyText: bounded.bodyText,
|
||||
upstreamBodyBytes: rawBodyText.length,
|
||||
returnedBodyBytes: Math.min(transformed.bodyText.length, 1024 * 1024),
|
||||
truncated: transformed.bodyText.length > 1024 * 1024,
|
||||
transformedBodyBytes: transformed.bodyText.length,
|
||||
returnedBodyBytes: bounded.bodyText.length,
|
||||
responseBodyLimitBytes: microserviceHttpMaxBodyTextLength,
|
||||
truncated: bounded.truncated,
|
||||
transform: transformed.transform,
|
||||
upstreamDurationMs: Date.now() - requestStartedAt,
|
||||
};
|
||||
})();
|
||||
if (cacheable) microserviceHttpInFlight.set(cacheKey, requestPromise);
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
if (cacheable) rememberMicroserviceHttpCache(cacheKey, cacheTtlMs, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
serviceId: payloadString(payload, "serviceId") ?? "unknown",
|
||||
serviceId,
|
||||
method,
|
||||
url: url.toString(),
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
if (cacheable && microserviceHttpInFlight.get(cacheKey) === requestPromise) microserviceHttpInFlight.delete(cacheKey);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
|
||||
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
|
||||
const verboseLifecycle = message.command !== "microservice.http";
|
||||
if (verboseLifecycle) await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
|
||||
try {
|
||||
await sendTaskStatus(message.taskId, "running", "provider running task");
|
||||
if (verboseLifecycle) await sendTaskStatus(message.taskId, "running", "provider running task");
|
||||
if (message.command === "docker.ps") {
|
||||
const result = await runDockerPs();
|
||||
await sendTaskStatus(message.taskId, "succeeded", "docker ps completed", result);
|
||||
|
||||
Reference in New Issue
Block a user