fix: fallback to in-memory state when convex functions are unavailable
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: {} } });
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user