Limit the app container to 1 CPU and 2 GB, and stop stale caches
CI / check (push) Canceled after 0s
CI / check (push) Canceled after 0s
The app container is now created with --cpus 1 --memory 2048m: the heavy work happens in the computers' own containers, so the orchestrator does not need more. Both are installer flags (--cpus, --memory), kept in install.conf, and part of the idempotency check, so an existing install picks them up on its next update. The env and app-setting caches lived in module variables, and bunext bundles pages and API routes separately. A save cleared only its own copy, so a saved provider key never reached the page that lists models until the process restarted. They now share one slot per name on globalThis, behind a symbol from the global registry, with the pattern extracted into a tested helper.
This commit is contained in:
@@ -1,14 +1,25 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
||||
import globalCache from "@/src/utils/global-cache";
|
||||
|
||||
let cache: Map<string, string> | null = null;
|
||||
/**
|
||||
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||
* in a module variable: the page and API bundles each carry their own copy
|
||||
* of this module, so a module-scoped cache would let a save clear its own
|
||||
* copy while another page kept serving a stale one until the next restart.
|
||||
*/
|
||||
const cache = globalCache<Map<string, string>>({
|
||||
name: "cobble.grabAppSettings.cache",
|
||||
});
|
||||
|
||||
/**
|
||||
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
||||
* `saveAppSetting` and `deleteAppSetting` clear it.
|
||||
*/
|
||||
export default async function grabAppSettings() {
|
||||
if (cache) return cache;
|
||||
const cached = cache.get();
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
||||
sql: `SELECT key, value FROM app_settings`,
|
||||
@@ -21,7 +32,7 @@ export default async function grabAppSettings() {
|
||||
if (row.key) map.set(row.key, row.value || "");
|
||||
}
|
||||
|
||||
cache = map;
|
||||
cache.set(map);
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -30,5 +41,5 @@ export default async function grabAppSettings() {
|
||||
* Drops the cache, so the next read sees the database.
|
||||
*/
|
||||
export function clearAppSettingsCache() {
|
||||
cache = null;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||
import type { EnvParadigm } from "@/src/dict/env-dict";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import globalCache from "@/src/utils/global-cache";
|
||||
|
||||
export type DecryptedEnv = {
|
||||
id: number;
|
||||
@@ -16,7 +17,16 @@ export type DecryptedEnv = {
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
|
||||
let cache: Promise<DecryptedEnv[]> | undefined;
|
||||
/**
|
||||
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||
* in a module variable: the page and API bundles each carry their own copy
|
||||
* of this module, so a module-scoped cache would let `save-env` clear its
|
||||
* own copy while the page kept serving a stale one until the next restart
|
||||
* (a saved API key never reached the models shown in the app).
|
||||
*/
|
||||
const cache = globalCache<Promise<DecryptedEnv[]>>({
|
||||
name: "cobble.grabAllEnvs.cache",
|
||||
});
|
||||
|
||||
/**
|
||||
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
||||
@@ -24,16 +34,22 @@ let cache: Promise<DecryptedEnv[]> | undefined;
|
||||
* encryption key changed) reads as empty.
|
||||
*/
|
||||
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
||||
cache ||= load().catch((error) => {
|
||||
cache = undefined;
|
||||
const cached = cache.get();
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
const loading = load().catch((error: unknown) => {
|
||||
cache.clear();
|
||||
throw error;
|
||||
});
|
||||
|
||||
return cache;
|
||||
cache.set(loading);
|
||||
|
||||
return loading;
|
||||
}
|
||||
|
||||
export function clearEnvCache() {
|
||||
cache = undefined;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import globalCache from "./global-cache";
|
||||
|
||||
describe("globalCache", () => {
|
||||
test("keeps a value and clears it", () => {
|
||||
const cache = globalCache<number>({ name: "cobble.grabAllEnvs.cache" });
|
||||
|
||||
cache.clear();
|
||||
expect(cache.get()).toBeUndefined();
|
||||
|
||||
cache.set(7);
|
||||
expect(cache.get()).toBe(7);
|
||||
|
||||
cache.clear();
|
||||
expect(cache.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("two slots with one name share a value, as two bundles would", () => {
|
||||
const page_bundle = globalCache<string>({
|
||||
name: "cobble.grabAppSettings.cache",
|
||||
});
|
||||
const api_bundle = globalCache<string>({
|
||||
name: "cobble.grabAppSettings.cache",
|
||||
});
|
||||
|
||||
page_bundle.clear();
|
||||
page_bundle.set("stale");
|
||||
|
||||
// The API sees what the page wrote, so a save there clears it too.
|
||||
expect(api_bundle.get()).toBe("stale");
|
||||
|
||||
api_bundle.clear();
|
||||
expect(page_bundle.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("different names never share a slot", () => {
|
||||
const envs = globalCache<string>({ name: "cobble.grabAllEnvs.cache" });
|
||||
const settings = globalCache<string>({
|
||||
name: "cobble.grabAppSettings.cache",
|
||||
});
|
||||
|
||||
envs.clear();
|
||||
settings.clear();
|
||||
|
||||
envs.set("a");
|
||||
settings.set("b");
|
||||
|
||||
expect(envs.get()).toBe("a");
|
||||
expect(settings.get()).toBe("b");
|
||||
|
||||
envs.clear();
|
||||
settings.clear();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* A per-process cache slot that survives module duplication.
|
||||
*
|
||||
* bunext bundles pages and API routes separately, so a module-scoped `let`
|
||||
* cache exists once per bundle: a save that clears its own copy leaves the
|
||||
* other one serving stale data until the process restarts (a saved API key
|
||||
* never reached the model list). The key is taken from the global symbol
|
||||
* registry, which resolves to the same symbol in every bundle, and the value
|
||||
* is stored on `globalThis`, so every copy reads and clears one slot.
|
||||
*/
|
||||
type Slot<Value> = {
|
||||
get(): Value | undefined;
|
||||
set(value: Value): void;
|
||||
clear(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Named caches, so two modules never share a slot by accident.
|
||||
*/
|
||||
export type GlobalCacheName =
|
||||
| "cobble.grabAllEnvs.cache"
|
||||
| "cobble.grabAppSettings.cache";
|
||||
|
||||
export default function globalCache<Value>({
|
||||
name,
|
||||
}: {
|
||||
name: GlobalCacheName;
|
||||
}): Slot<Value> {
|
||||
const key = Symbol.for(name);
|
||||
const holder = globalThis as Record<symbol, Value | undefined>;
|
||||
|
||||
return {
|
||||
get: () => holder[key],
|
||||
set: (value: Value) => {
|
||||
holder[key] = value;
|
||||
},
|
||||
clear: () => {
|
||||
delete holder[key];
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user