fix: fallback to in-memory state when convex functions are unavailable

This commit is contained in:
Codex
2026-02-18 14:24:36 +00:00
parent 53a0e3576e
commit 42684125f9
5 changed files with 134 additions and 4 deletions

View File

@@ -32,6 +32,21 @@ class JsonFileStateStore {
} }
} }
class InMemoryStateStore {
constructor(initialState = null) {
this.state = initialState;
}
async load() {
return this.state;
}
async save(state) {
this.state = state;
}
}
module.exports = { module.exports = {
JsonFileStateStore, JsonFileStateStore,
InMemoryStateStore,
}; };

View File

@@ -4,6 +4,7 @@ const http = require("node:http");
const { buildApp } = require("./app"); const { buildApp } = require("./app");
const { config } = require("./config"); const { config } = require("./config");
const { ConvexStateStore } = require("./lib/convex-state-store"); const { ConvexStateStore } = require("./lib/convex-state-store");
const { InMemoryStateStore } = require("./lib/state-store");
const { createLogger } = require("./lib/logger"); const { createLogger } = require("./lib/logger");
function readBody(req) { function readBody(req) {
@@ -81,13 +82,23 @@ function createMutationPersister({ stateStore, logger = console }) {
} }
async function createRuntime({ runtimeConfig = config, logger = console, stateStore = null } = {}) { async function createRuntime({ runtimeConfig = config, logger = console, stateStore = null } = {}) {
const effectiveStateStore = stateStore || new ConvexStateStore({ let effectiveStateStore = stateStore || new ConvexStateStore({
deploymentUrl: runtimeConfig.convexDeploymentUrl, deploymentUrl: runtimeConfig.convexDeploymentUrl,
authToken: runtimeConfig.convexAuthToken, authToken: runtimeConfig.convexAuthToken,
readFunction: runtimeConfig.convexStateQuery, readFunction: runtimeConfig.convexStateQuery,
writeFunction: runtimeConfig.convexStateMutation, writeFunction: runtimeConfig.convexStateMutation,
}); });
const initialState = await effectiveStateStore.load(); let initialState;
try {
initialState = await effectiveStateStore.load();
} catch (error) {
logger.warn(
{ err: error },
"failed to initialize configured state store; falling back to in-memory state",
);
effectiveStateStore = new InMemoryStateStore();
initialState = await effectiveStateStore.load();
}
const persister = createMutationPersister({ stateStore: effectiveStateStore, logger }); const persister = createMutationPersister({ stateStore: effectiveStateStore, logger });
const app = buildApp({ const app = buildApp({

View File

@@ -36,6 +36,7 @@ test("config uses defaults when env is missing", () => {
APP_BASE_URL: "", APP_BASE_URL: "",
BETTER_AUTH_SECRET: "", BETTER_AUTH_SECRET: "",
BETTER_AUTH_BASE_PATH: "", BETTER_AUTH_BASE_PATH: "",
INTERNAL_API_TOKEN: "",
QWEN_TTS_MODEL: "", QWEN_TTS_MODEL: "",
MINIO_SIGNED_URL_TTL_SEC: "", MINIO_SIGNED_URL_TTL_SEC: "",
MINIO_USE_SSL: "", MINIO_USE_SSL: "",

View File

@@ -2,7 +2,65 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { mapToAppRequest, normalizeHeaders, createMutationPersister } = require("../src/server"); const {
mapToAppRequest,
normalizeHeaders,
createMutationPersister,
createRuntime,
} = require("../src/server");
function createRuntimeConfig() {
return {
port: 3000,
logLevel: "info",
appBaseUrl: "http://localhost:3000",
betterAuthSecret: "test-better-auth-secret",
betterAuthBasePath: "/api/auth",
betterAuthDevPassword: "xartaudio-dev-password",
internalApiToken: "",
convexDeploymentUrl: "",
convexAuthToken: "",
convexStateQuery: "state:getLatestSnapshot",
convexStateMutation: "state:saveSnapshot",
xWebhookSecret: "x-secret",
xBearerToken: "",
xBotUserId: "",
polarWebhookSecret: "polar-secret",
polarAccessToken: "",
polarServer: "production",
polarProductIds: [],
qwenTtsApiKey: "",
qwenTtsBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
qwenTtsModel: "qwen-tts-latest",
qwenTtsVoice: "Cherry",
qwenTtsFormat: "mp3",
minioEndPoint: "",
minioPort: 443,
minioUseSSL: true,
minioBucket: "",
minioRegion: "us-east-1",
minioAccessKey: "",
minioSecretKey: "",
minioSignedUrlTtlSec: 3600,
rateLimits: {
webhookPerMinute: 120,
authPerMinute: 30,
actionPerMinute: 60,
},
abuse: {
maxJobsPerUserPerDay: 0,
cooldownSec: 0,
denyUserIds: [],
},
credit: {
baseCredits: 1,
includedChars: 25000,
stepChars: 10000,
stepCredits: 1,
maxCharsPerArticle: 120000,
},
};
}
test("normalizeHeaders lowercases and joins array values", () => { test("normalizeHeaders lowercases and joins array values", () => {
const headers = normalizeHeaders({ const headers = normalizeHeaders({
@@ -49,3 +107,39 @@ test("createMutationPersister writes sequentially and flush waits", async () =>
assert.deepEqual(saved, ["s1", "s2"]); assert.deepEqual(saved, ["s1", "s2"]);
}); });
test("createRuntime falls back to in-memory state when initial load fails", async () => {
const warnings = [];
const runtime = await createRuntime({
runtimeConfig: createRuntimeConfig(),
logger: {
warn(payload, message) {
warnings.push({ payload, message });
},
info() {},
error() {},
},
stateStore: {
async load() {
throw new Error("state_load_failed");
},
async save() {
throw new Error("state_save_should_not_run");
},
},
});
assert.equal(warnings.length, 1);
assert.match(String(warnings[0].message), /falling back to in-memory state/);
const response = await runtime.app.handleRequest({
method: "POST",
path: "/app/actions/topup",
headers: { "x-user-id": "u1" },
rawBody: "amount=3",
query: {},
});
assert.equal(response.status, 303);
await runtime.persister.flush();
});

View File

@@ -5,7 +5,7 @@ const assert = require("node:assert/strict");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs/promises"); const fs = require("node:fs/promises");
const { JsonFileStateStore } = require("../src/lib/state-store"); const { JsonFileStateStore, InMemoryStateStore } = require("../src/lib/state-store");
test("load returns null when state file does not exist", async () => { test("load returns null when state file does not exist", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "xartaudio-state-test-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "xartaudio-state-test-"));
@@ -33,3 +33,12 @@ test("save and load roundtrip state", async () => {
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
}); });
test("in memory state store roundtrips without filesystem", async () => {
const store = new InMemoryStateStore();
assert.equal(await store.load(), null);
await store.save({ engine: { jobs: {} } });
const loaded = await store.load();
assert.deepEqual(loaded, { engine: { jobs: {} } });
});