latest
This commit is contained in:
171
.agents/skills/edge-config/SKILL.md
Normal file
171
.agents/skills/edge-config/SKILL.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
name: edge-config
|
||||||
|
description: Understand and create S3-backed edge configs with poll-based caching. Use when adding new runtime configs (feature rollouts, request blocking, operational toggles), debugging edge config polling, or working with the EdgeConfigStore factory.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Edge Configs
|
||||||
|
|
||||||
|
Edge configs are JSON files stored in S3 that are polled into server memory at a regular cadence. They let us change server behavior at runtime without deploys -- load shedding, feature rollouts, operational toggles, etc.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Each edge config has its own S3 file under the `admin/` prefix in the admin S3 bucket (`autumn-dev-server` / `autumn-prod-server`). Configs are independent -- a parse error in one does not affect another.
|
||||||
|
|
||||||
|
### Key files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server/src/internal/misc/edgeConfig/edgeConfigStore.ts` | `createEdgeConfigStore<T>` factory -- generic S3 read/write, polling, status tracking |
|
||||||
|
| `server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts` | Registry -- `registerEdgeConfig`, `startAllEdgeConfigPolling`, `stopAllEdgeConfigPolling` |
|
||||||
|
| `server/src/init.ts` | Calls `startAllEdgeConfigPolling` on boot, `stopAllEdgeConfigPolling` on shutdown |
|
||||||
|
| `server/src/external/aws/s3/adminS3Config.ts` | Bucket/region resolution (dev vs prod) |
|
||||||
|
|
||||||
|
### Existing configs
|
||||||
|
|
||||||
|
| Config | S3 Key | Module |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| Request blocks | `admin/request-block-config.json` | `server/src/internal/misc/requestBlocks/requestBlockStore.ts` |
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Boot:
|
||||||
|
1. Config module is imported (top-level side effect) -> calls registerEdgeConfig()
|
||||||
|
2. init.ts calls startAllEdgeConfigPolling({ logger })
|
||||||
|
3. Each store: await refresh() (initial S3 fetch, blocks until done)
|
||||||
|
4. Each store: setInterval(refresh, pollIntervalMs)
|
||||||
|
5. Server starts accepting traffic
|
||||||
|
|
||||||
|
Runtime:
|
||||||
|
- get() reads from in-memory cache (zero I/O, sync)
|
||||||
|
- refresh() runs every pollIntervalMs (fire-and-forget)
|
||||||
|
- writeToSource() writes to S3 + updates local cache immediately
|
||||||
|
|
||||||
|
Shutdown:
|
||||||
|
- stopAllEdgeConfigPolling() clears all intervals
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fail-Open Guarantee
|
||||||
|
|
||||||
|
On **any** S3 failure (network error, auth error, bad JSON, schema mismatch), `runtimeConfig` resets to `defaultValue()`. This means:
|
||||||
|
|
||||||
|
- No request is ever blocked due to infrastructure failure
|
||||||
|
- If S3 goes down, configs degrade to their default (empty/safe) state
|
||||||
|
- The store does NOT hold onto stale data -- it resets to default
|
||||||
|
|
||||||
|
`NoSuchKey` (file doesn't exist yet) is treated as a normal empty state, not an error.
|
||||||
|
|
||||||
|
## How to Add a New Edge Config
|
||||||
|
|
||||||
|
### 1. Define the schema
|
||||||
|
|
||||||
|
Create a schemas file with a Zod schema:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/src/internal/misc/featureRollouts/featureRolloutSchemas.ts
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const FeatureRolloutConfigSchema = z.object({
|
||||||
|
features: z.record(z.string(), z.object({
|
||||||
|
percentage: z.number().min(0).max(100).default(0),
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
})).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FeatureRolloutConfig = z.infer<typeof FeatureRolloutConfigSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the store module
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/src/internal/misc/featureRollouts/featureRolloutStore.ts
|
||||||
|
import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js";
|
||||||
|
import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js";
|
||||||
|
import {
|
||||||
|
type FeatureRolloutConfig,
|
||||||
|
FeatureRolloutConfigSchema,
|
||||||
|
} from "./featureRolloutSchemas.js";
|
||||||
|
|
||||||
|
const store = createEdgeConfigStore<FeatureRolloutConfig>({
|
||||||
|
s3Key: "admin/feature-rollout-config.json",
|
||||||
|
schema: FeatureRolloutConfigSchema,
|
||||||
|
defaultValue: () => ({ features: {} }),
|
||||||
|
pollIntervalMs: 300_000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
registerEdgeConfig({ store });
|
||||||
|
|
||||||
|
export const getFeatureRolloutConfig = () => store.get();
|
||||||
|
|
||||||
|
export const isFeatureEnabled = ({
|
||||||
|
featureId,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
}): boolean => {
|
||||||
|
const feature = store.get().features[featureId];
|
||||||
|
return feature?.enabled ?? false;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register the import in init.ts
|
||||||
|
|
||||||
|
Add a side-effect import so the module runs `registerEdgeConfig` at boot:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/src/init.ts
|
||||||
|
import "./internal/misc/featureRollouts/featureRolloutStore.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. (Optional) Add the S3 key constant
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/src/external/aws/s3/adminS3Config.ts
|
||||||
|
export const ADMIN_FEATURE_ROLLOUT_CONFIG_KEY = "admin/feature-rollout-config.json";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use it
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { isFeatureEnabled } from "@/internal/misc/featureRollouts/featureRolloutStore.js";
|
||||||
|
|
||||||
|
if (isFeatureEnabled({ featureId: "new-billing-engine" })) {
|
||||||
|
// new path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Factory API Reference
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const store = createEdgeConfigStore<T>({
|
||||||
|
s3Key: string, // S3 object key under admin bucket
|
||||||
|
schema: z.ZodType<T>, // Zod schema for validation
|
||||||
|
defaultValue: () => T, // Factory for empty/safe default
|
||||||
|
pollIntervalMs?: number, // Poll interval (default: 60_000)
|
||||||
|
s3Client?: S3Client, // Optional DI for testing
|
||||||
|
});
|
||||||
|
|
||||||
|
store.get() // T -- in-memory cached config (sync, no I/O)
|
||||||
|
store.getStatus() // EdgeConfigStatus -- health/timing info
|
||||||
|
store.refresh({ logger? }) // Re-fetch from S3, update cache
|
||||||
|
store.startPolling({ logger? }) // Initial fetch + start interval
|
||||||
|
store.stopPolling() // Clear interval
|
||||||
|
store.readFromSource() // Direct S3 read (bypasses cache)
|
||||||
|
store.writeToSource({ config }) // S3 write + update cache immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests use the `s3Client` DI parameter to inject a mock:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mockClient = { send: jest.fn(async () => ({ Body: ... })) } as unknown as S3Client;
|
||||||
|
|
||||||
|
const store = createEdgeConfigStore<MyConfig>({
|
||||||
|
s3Key: "admin/test.json",
|
||||||
|
schema: MyConfigSchema,
|
||||||
|
defaultValue: () => ({ ... }),
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing tests: `server/tests/unit/edge-config/edge-config-store.test.ts`
|
||||||
@@ -22,8 +22,6 @@ export const requestBlockMiddleware = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Entry:", entry);
|
|
||||||
|
|
||||||
if (entry.blockAll) {
|
if (entry.blockAll) {
|
||||||
ctx.logger.warn("Rejecting blocked /v1 request (block all)");
|
ctx.logger.warn("Rejecting blocked /v1 request (block all)");
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import {
|
|||||||
} from "./db/pgHealthMonitor.js";
|
} from "./db/pgHealthMonitor.js";
|
||||||
import { logger } from "./external/logtail/logtailUtils.js";
|
import { logger } from "./external/logtail/logtailUtils.js";
|
||||||
import {
|
import {
|
||||||
startRequestBlockPolling,
|
startAllEdgeConfigPolling,
|
||||||
stopRequestBlockPolling,
|
stopAllEdgeConfigPolling,
|
||||||
} from "./internal/misc/requestBlocks/requestBlockStore.js";
|
} from "./internal/misc/edgeConfig/edgeConfigRegistry.js";
|
||||||
|
|
||||||
|
// Edge config modules self-register on import
|
||||||
|
import "./internal/misc/requestBlocks/requestBlockStore.js";
|
||||||
import { warmupRegionalRedis } from "./external/redis/initRedis.js";
|
import { warmupRegionalRedis } from "./external/redis/initRedis.js";
|
||||||
import { createHonoApp } from "./initHono.js";
|
import { createHonoApp } from "./initHono.js";
|
||||||
import { otelSdk } from "./instrumentation.js";
|
import { otelSdk } from "./instrumentation.js";
|
||||||
@@ -28,7 +31,7 @@ const init = async () => {
|
|||||||
|
|
||||||
initPgHealthMonitor({ client: clientCritical });
|
initPgHealthMonitor({ client: clientCritical });
|
||||||
await Promise.all([warmupRegionalRedis()]);
|
await Promise.all([warmupRegionalRedis()]);
|
||||||
await startRequestBlockPolling({ logger });
|
await startAllEdgeConfigPolling({ logger });
|
||||||
|
|
||||||
const PORT = process.env.SERVER_PORT
|
const PORT = process.env.SERVER_PORT
|
||||||
? Number.parseInt(process.env.SERVER_PORT)
|
? Number.parseInt(process.env.SERVER_PORT)
|
||||||
@@ -88,7 +91,7 @@ async function gracefulShutdown() {
|
|||||||
await otelSdk.shutdown();
|
await otelSdk.shutdown();
|
||||||
}
|
}
|
||||||
shutdownPgHealthMonitor();
|
shutdownPgHealthMonitor();
|
||||||
stopRequestBlockPolling();
|
stopAllEdgeConfigPolling();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
client.end(),
|
client.end(),
|
||||||
clientCritical.end(),
|
clientCritical.end(),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const handleGetAdminOrgRequestBlock = createRoute({
|
|||||||
handler: async (c) => {
|
handler: async (c) => {
|
||||||
const { org_id: orgId } = c.req.valid("param");
|
const { org_id: orgId } = c.req.valid("param");
|
||||||
const status = getRuntimeRequestBlockStatus();
|
const status = getRuntimeRequestBlockStatus();
|
||||||
const entry = await getOrgRequestBlockFromSource(orgId);
|
const entry = await getOrgRequestBlockFromSource({ orgId });
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
blockAll: entry?.blockAll ?? false,
|
blockAll: entry?.blockAll ?? false,
|
||||||
|
|||||||
28
server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts
Normal file
28
server/src/internal/misc/edgeConfig/edgeConfigRegistry.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
||||||
|
|
||||||
|
type EdgeConfigLifecycle = {
|
||||||
|
startPolling: (options?: { logger?: Logger }) => Promise<void>;
|
||||||
|
stopPolling: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stores: EdgeConfigLifecycle[] = [];
|
||||||
|
|
||||||
|
export const registerEdgeConfig = ({
|
||||||
|
store,
|
||||||
|
}: {
|
||||||
|
store: EdgeConfigLifecycle;
|
||||||
|
}) => {
|
||||||
|
stores.push(store);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startAllEdgeConfigPolling = async ({
|
||||||
|
logger,
|
||||||
|
}: {
|
||||||
|
logger?: Logger;
|
||||||
|
} = {}) => {
|
||||||
|
await Promise.all(stores.map((store) => store.startPolling({ logger })));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopAllEdgeConfigPolling = () => {
|
||||||
|
for (const store of stores) store.stopPolling();
|
||||||
|
};
|
||||||
187
server/src/internal/misc/edgeConfig/edgeConfigStore.ts
Normal file
187
server/src/internal/misc/edgeConfig/edgeConfigStore.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { ErrCode, ms } from "@autumn/shared";
|
||||||
|
import type { S3Client } from "@aws-sdk/client-s3";
|
||||||
|
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import type { z } from "zod/v4";
|
||||||
|
import { getAdminS3Config } from "@/external/aws/s3/adminS3Config.js";
|
||||||
|
import { getS3Client } from "@/external/aws/s3/initS3.js";
|
||||||
|
import { getS3BodyAsString } from "@/external/aws/s3/s3Utils.js";
|
||||||
|
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
||||||
|
import RecaseError from "@/utils/errorUtils.js";
|
||||||
|
|
||||||
|
export type EdgeConfigStatus = {
|
||||||
|
configured: boolean;
|
||||||
|
healthy: boolean;
|
||||||
|
lastFetchAt?: string;
|
||||||
|
lastSuccessAt?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory that creates a typed, poll-based edge config backed by S3.
|
||||||
|
* Fail-open: any S3 error resets the in-memory config to `defaultValue()`.
|
||||||
|
*/
|
||||||
|
export const createEdgeConfigStore = <T>({
|
||||||
|
s3Key,
|
||||||
|
schema,
|
||||||
|
defaultValue,
|
||||||
|
pollIntervalMs = ms.seconds(30),
|
||||||
|
s3Client: injectedS3Client,
|
||||||
|
}: {
|
||||||
|
s3Key: string;
|
||||||
|
schema: z.ZodType<T>;
|
||||||
|
defaultValue: () => T;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
s3Client?: S3Client;
|
||||||
|
}) => {
|
||||||
|
let runtimeConfig: T = defaultValue();
|
||||||
|
let runtimeStatus: EdgeConfigStatus = {
|
||||||
|
configured: false,
|
||||||
|
healthy: false,
|
||||||
|
error: "Edge config not yet initialized",
|
||||||
|
};
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const getConfigLocation = () => {
|
||||||
|
const { bucket, region } = getAdminS3Config();
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
key: s3Key,
|
||||||
|
configured: Boolean(bucket && s3Key),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveClient = () => {
|
||||||
|
if (injectedS3Client) return injectedS3Client;
|
||||||
|
const { region } = getConfigLocation();
|
||||||
|
return getS3Client({ region });
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFromSource = async (): Promise<T> => {
|
||||||
|
const { bucket, key, configured } = getConfigLocation();
|
||||||
|
|
||||||
|
if (!configured || !bucket || !key) return defaultValue();
|
||||||
|
|
||||||
|
const client = resolveClient();
|
||||||
|
try {
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.Body) return defaultValue();
|
||||||
|
|
||||||
|
const raw = (await getS3BodyAsString({ body: response.Body })).trim();
|
||||||
|
if (!raw) return defaultValue();
|
||||||
|
|
||||||
|
return schema.parse(JSON.parse(raw));
|
||||||
|
} catch (error) {
|
||||||
|
const name = error instanceof Error ? error.name : "";
|
||||||
|
if (name === "NoSuchKey") return defaultValue();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeToSource = async ({ config }: { config: T }) => {
|
||||||
|
const { bucket, key, configured } = getConfigLocation();
|
||||||
|
|
||||||
|
if (!configured || !bucket || !key) {
|
||||||
|
throw new RecaseError({
|
||||||
|
message: "Edge config S3 is not configured",
|
||||||
|
code: ErrCode.InvalidRequest,
|
||||||
|
statusCode: 503,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = resolveClient();
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: JSON.stringify(config, null, 2),
|
||||||
|
ContentType: "application/json",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
runtimeConfig = config;
|
||||||
|
runtimeStatus = {
|
||||||
|
configured: true,
|
||||||
|
healthy: true,
|
||||||
|
lastFetchAt: nowIso(),
|
||||||
|
lastSuccessAt: nowIso(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async ({ logger }: { logger?: Logger } = {}) => {
|
||||||
|
const { configured } = getConfigLocation();
|
||||||
|
runtimeStatus = {
|
||||||
|
...runtimeStatus,
|
||||||
|
configured,
|
||||||
|
lastFetchAt: nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
runtimeConfig = defaultValue();
|
||||||
|
runtimeStatus = {
|
||||||
|
configured: false,
|
||||||
|
healthy: false,
|
||||||
|
lastFetchAt: runtimeStatus.lastFetchAt,
|
||||||
|
lastSuccessAt: runtimeStatus.lastSuccessAt,
|
||||||
|
error: "Edge config S3 is not configured",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await readFromSource();
|
||||||
|
runtimeConfig = config;
|
||||||
|
runtimeStatus = {
|
||||||
|
configured: true,
|
||||||
|
healthy: true,
|
||||||
|
lastFetchAt: runtimeStatus.lastFetchAt,
|
||||||
|
lastSuccessAt: nowIso(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
runtimeConfig = defaultValue();
|
||||||
|
runtimeStatus = {
|
||||||
|
configured: true,
|
||||||
|
healthy: false,
|
||||||
|
lastFetchAt: runtimeStatus.lastFetchAt,
|
||||||
|
lastSuccessAt: runtimeStatus.lastSuccessAt,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to load config",
|
||||||
|
};
|
||||||
|
logger?.warn(`Failed to refresh edge config "${s3Key}": ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = async ({ logger }: { logger?: Logger } = {}) => {
|
||||||
|
if (pollTimer) return;
|
||||||
|
|
||||||
|
await refresh({ logger });
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
void refresh({ logger });
|
||||||
|
}, pollIntervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => runtimeConfig,
|
||||||
|
getStatus: () => runtimeStatus,
|
||||||
|
refresh,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
readFromSource,
|
||||||
|
writeToSource,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EdgeConfigStore<T = unknown> = ReturnType<
|
||||||
|
typeof createEdgeConfigStore<T>
|
||||||
|
>;
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import { ErrCode } from "@autumn/shared";
|
import { ADMIN_REQUEST_BLOCK_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js";
|
||||||
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js";
|
||||||
import {
|
import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js";
|
||||||
ADMIN_REQUEST_BLOCK_CONFIG_KEY,
|
|
||||||
getAdminS3Config,
|
|
||||||
} from "@/external/aws/s3/adminS3Config.js";
|
|
||||||
import { getS3Client } from "@/external/aws/s3/initS3.js";
|
|
||||||
import { getS3BodyAsString } from "@/external/aws/s3/s3Utils.js";
|
|
||||||
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
|
||||||
import RecaseError from "@/utils/errorUtils.js";
|
|
||||||
import {
|
import {
|
||||||
type RequestBlockConfig,
|
type RequestBlockConfig,
|
||||||
RequestBlockConfigSchema,
|
RequestBlockConfigSchema,
|
||||||
@@ -15,179 +8,32 @@ import {
|
|||||||
type RequestBlockUpdate,
|
type RequestBlockUpdate,
|
||||||
} from "./requestBlockSchemas.js";
|
} from "./requestBlockSchemas.js";
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 60_000;
|
|
||||||
|
|
||||||
type RequestBlockStatus = {
|
|
||||||
configured: boolean;
|
|
||||||
healthy: boolean;
|
|
||||||
lastFetchAt?: string;
|
|
||||||
lastSuccessAt?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyConfig = (): RequestBlockConfig => ({ orgs: {} });
|
|
||||||
|
|
||||||
let runtimeConfig: RequestBlockConfig = emptyConfig();
|
|
||||||
let runtimeStatus: RequestBlockStatus = {
|
|
||||||
configured: false,
|
|
||||||
healthy: false,
|
|
||||||
error: "Request block config is not configured",
|
|
||||||
};
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
const getConfigLocation = () => {
|
|
||||||
const { bucket, region } = getAdminS3Config();
|
|
||||||
const key = ADMIN_REQUEST_BLOCK_CONFIG_KEY;
|
|
||||||
|
|
||||||
return {
|
|
||||||
bucket,
|
|
||||||
key,
|
|
||||||
region,
|
|
||||||
configured: Boolean(bucket && key),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const readConfigFromS3 = async (): Promise<RequestBlockConfig> => {
|
|
||||||
const { bucket, key, configured, region } = getConfigLocation();
|
|
||||||
|
|
||||||
if (!configured || !bucket || !key) {
|
|
||||||
return emptyConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = getS3Client({ region });
|
|
||||||
try {
|
|
||||||
const response = await client.send(
|
|
||||||
new GetObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: key,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.Body) {
|
|
||||||
return emptyConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = (await getS3BodyAsString({ body: response.Body })).trim();
|
|
||||||
if (!raw) {
|
|
||||||
return emptyConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
return RequestBlockConfigSchema.parse(JSON.parse(raw));
|
|
||||||
} catch (error) {
|
|
||||||
const name = error instanceof Error ? error.name : "";
|
|
||||||
if (name === "NoSuchKey") {
|
|
||||||
return emptyConfig();
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeConfigToS3 = async (config: RequestBlockConfig) => {
|
|
||||||
const { bucket, key, configured, region } = getConfigLocation();
|
|
||||||
|
|
||||||
if (!configured || !bucket || !key) {
|
|
||||||
throw new RecaseError({
|
|
||||||
message: "Request block config is not configured",
|
|
||||||
code: ErrCode.InvalidRequest,
|
|
||||||
statusCode: 503,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = getS3Client({ region });
|
|
||||||
await client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: key,
|
|
||||||
Body: JSON.stringify(config, null, 2),
|
|
||||||
ContentType: "application/json",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nowIso = () => new Date().toISOString();
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
export const getRuntimeRequestBlockStatus = () => runtimeStatus;
|
const store = createEdgeConfigStore<RequestBlockConfig>({
|
||||||
|
s3Key: ADMIN_REQUEST_BLOCK_CONFIG_KEY,
|
||||||
|
schema: RequestBlockConfigSchema,
|
||||||
|
defaultValue: () => ({ orgs: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
registerEdgeConfig({ store });
|
||||||
|
|
||||||
|
export const getRuntimeRequestBlockStatus = () => store.getStatus();
|
||||||
|
|
||||||
export const getRuntimeRequestBlockEntry = (
|
export const getRuntimeRequestBlockEntry = (
|
||||||
orgId: string,
|
orgId: string,
|
||||||
): RequestBlockEntry | undefined => runtimeConfig.orgs[orgId];
|
): RequestBlockEntry | undefined => store.get().orgs[orgId];
|
||||||
|
|
||||||
export const getRuntimeRequestBlockConfig = () => runtimeConfig;
|
|
||||||
|
|
||||||
export const refreshRequestBlockConfig = async ({
|
|
||||||
logger,
|
|
||||||
}: {
|
|
||||||
logger?: Logger;
|
|
||||||
} = {}) => {
|
|
||||||
const { configured } = getConfigLocation();
|
|
||||||
runtimeStatus = {
|
|
||||||
...runtimeStatus,
|
|
||||||
configured,
|
|
||||||
lastFetchAt: nowIso(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!configured) {
|
|
||||||
runtimeConfig = emptyConfig();
|
|
||||||
runtimeStatus = {
|
|
||||||
configured: false,
|
|
||||||
healthy: false,
|
|
||||||
lastFetchAt: runtimeStatus.lastFetchAt,
|
|
||||||
lastSuccessAt: runtimeStatus.lastSuccessAt,
|
|
||||||
error: "Request block config is not configured",
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await readConfigFromS3();
|
|
||||||
runtimeConfig = config;
|
|
||||||
runtimeStatus = {
|
|
||||||
configured: true,
|
|
||||||
healthy: true,
|
|
||||||
lastFetchAt: runtimeStatus.lastFetchAt,
|
|
||||||
lastSuccessAt: nowIso(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
runtimeConfig = emptyConfig();
|
|
||||||
runtimeStatus = {
|
|
||||||
configured: true,
|
|
||||||
healthy: false,
|
|
||||||
lastFetchAt: runtimeStatus.lastFetchAt,
|
|
||||||
lastSuccessAt: runtimeStatus.lastSuccessAt,
|
|
||||||
error: error instanceof Error ? error.message : "Failed to load config",
|
|
||||||
};
|
|
||||||
logger?.warn(`Failed to refresh request block config: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startRequestBlockPolling = async ({
|
|
||||||
logger,
|
|
||||||
}: {
|
|
||||||
logger?: Logger;
|
|
||||||
} = {}) => {
|
|
||||||
if (pollTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshRequestBlockConfig({ logger });
|
|
||||||
pollTimer = setInterval(() => {
|
|
||||||
void refreshRequestBlockConfig({ logger });
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopRequestBlockPolling = () => {
|
|
||||||
if (pollTimer) {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
pollTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRequestBlockConfigFromSource = async () => {
|
export const getRequestBlockConfigFromSource = async () => {
|
||||||
return await readConfigFromS3();
|
return await store.readFromSource();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrgRequestBlockFromSource = async (orgId: string) => {
|
export const getOrgRequestBlockFromSource = async ({
|
||||||
const config = await readConfigFromS3();
|
orgId,
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
}) => {
|
||||||
|
const config = await store.readFromSource();
|
||||||
return config.orgs[orgId];
|
return config.orgs[orgId];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,7 +46,7 @@ export const updateOrgRequestBlockInSource = async ({
|
|||||||
update: RequestBlockUpdate;
|
update: RequestBlockUpdate;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const config = await readConfigFromS3();
|
const config = await store.readFromSource();
|
||||||
const shouldDelete = !update.blockAll && update.blockedEndpoints.length === 0;
|
const shouldDelete = !update.blockAll && update.blockedEndpoints.length === 0;
|
||||||
|
|
||||||
if (shouldDelete) {
|
if (shouldDelete) {
|
||||||
@@ -214,15 +60,7 @@ export const updateOrgRequestBlockInSource = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeConfigToS3(config);
|
await store.writeToSource({ config });
|
||||||
|
|
||||||
runtimeConfig = config;
|
|
||||||
runtimeStatus = {
|
|
||||||
configured: true,
|
|
||||||
healthy: true,
|
|
||||||
lastFetchAt: nowIso(),
|
|
||||||
lastSuccessAt: nowIso(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return config.orgs[orgId];
|
return config.orgs[orgId];
|
||||||
};
|
};
|
||||||
|
|||||||
389
server/tests/unit/edge-config/edge-config-store.test.ts
Normal file
389
server/tests/unit/edge-config/edge-config-store.test.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { afterEach, describe, expect, jest, test } from "bun:test";
|
||||||
|
import type { S3Client } from "@aws-sdk/client-s3";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js";
|
||||||
|
|
||||||
|
const TestConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
message: z.string().default("hello"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TestConfig = z.infer<typeof TestConfigSchema>;
|
||||||
|
|
||||||
|
const defaultConfig = (): TestConfig => ({ enabled: false, message: "hello" });
|
||||||
|
|
||||||
|
const createMockS3Client = ({
|
||||||
|
getResponse,
|
||||||
|
}: {
|
||||||
|
getResponse: () => {
|
||||||
|
Body?: { transformToString: () => Promise<string> } | null;
|
||||||
|
};
|
||||||
|
}): S3Client => {
|
||||||
|
const sendFn = jest.fn(async (command: unknown) => {
|
||||||
|
const commandName =
|
||||||
|
command?.constructor?.name ?? (command as { name?: string })?.name;
|
||||||
|
|
||||||
|
if (commandName === "GetObjectCommand") {
|
||||||
|
return getResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandName === "PutObjectCommand") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected command: ${commandName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { send: sendFn } as unknown as S3Client;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeBody = (data: unknown) => ({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => JSON.stringify(data),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeNoSuchKeyError = () => {
|
||||||
|
const error = new Error("NoSuchKey");
|
||||||
|
error.name = "NoSuchKey";
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createEdgeConfigStore", () => {
|
||||||
|
let store: ReturnType<typeof createEdgeConfigStore<TestConfig>>;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store?.stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial fetch via startPolling", () => {
|
||||||
|
test("populates get() with parsed config from S3", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => makeBody({ enabled: true, message: "from-s3" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual({ enabled: true, message: "from-s3" });
|
||||||
|
expect(store.getStatus().healthy).toBe(true);
|
||||||
|
expect(store.getStatus().configured).toBe(true);
|
||||||
|
expect(store.getStatus().lastSuccessAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses defaultValue before startPolling is called", () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => makeBody({ enabled: true, message: "from-s3" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fail-open behavior", () => {
|
||||||
|
test("returns defaultValue when S3 throws a network error", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => {
|
||||||
|
throw new Error("NetworkingError: socket hang up");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
expect(store.getStatus().healthy).toBe(false);
|
||||||
|
expect(store.getStatus().error).toContain("NetworkingError");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaultValue when S3 file does not exist (NoSuchKey)", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => {
|
||||||
|
throw makeNoSuchKeyError();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
expect(store.getStatus().healthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaultValue when S3 body is malformed JSON", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => ({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => "not-valid-json{{{",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
expect(store.getStatus().healthy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaultValue when S3 body is null", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => ({ Body: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
expect(store.getStatus().healthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaultValue when S3 body is empty string", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => ({
|
||||||
|
Body: { transformToString: async () => " " },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
expect(store.getStatus().healthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaultValue when schema validation fails", async () => {
|
||||||
|
const strictSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
requiredField: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () =>
|
||||||
|
makeBody({ enabled: true, message: "no-required-field" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: strictSchema as unknown as z.ZodType<TestConfig>,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
expect(store.get()).toEqual(defaultConfig());
|
||||||
|
expect(store.getStatus().healthy).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refresh", () => {
|
||||||
|
test("updates cached config when S3 content changes", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return makeBody({ enabled: false, message: "first" });
|
||||||
|
}
|
||||||
|
return makeBody({ enabled: true, message: "second" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
expect(store.get().message).toBe("first");
|
||||||
|
|
||||||
|
await store.refresh();
|
||||||
|
expect(store.get().message).toBe("second");
|
||||||
|
expect(store.get().enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeToSource", () => {
|
||||||
|
test("updates local cache immediately after write", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => makeBody(defaultConfig()),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
expect(store.get().enabled).toBe(false);
|
||||||
|
|
||||||
|
await store.writeToSource({
|
||||||
|
config: { enabled: true, message: "written" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get()).toEqual({ enabled: true, message: "written" });
|
||||||
|
expect(store.getStatus().healthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls S3 PutObject with correct payload", async () => {
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => makeBody(defaultConfig()),
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.writeToSource({
|
||||||
|
config: { enabled: true, message: "test-write" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendFn = (
|
||||||
|
mockClient as unknown as { send: ReturnType<typeof jest.fn> }
|
||||||
|
).send;
|
||||||
|
const calls = sendFn.mock.calls;
|
||||||
|
const lastCall = calls[calls.length - 1]?.[0];
|
||||||
|
expect(lastCall?.constructor?.name).toBe("PutObjectCommand");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readFromSource", () => {
|
||||||
|
test("returns fresh data from S3 without updating cache", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => {
|
||||||
|
callCount++;
|
||||||
|
return makeBody({
|
||||||
|
enabled: callCount > 1,
|
||||||
|
message: `call-${callCount}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
expect(store.get().message).toBe("call-1");
|
||||||
|
|
||||||
|
const fresh = await store.readFromSource();
|
||||||
|
expect(fresh.message).toBe("call-2");
|
||||||
|
expect(store.get().message).toBe("call-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("polling lifecycle", () => {
|
||||||
|
test("double startPolling does not create a second interval", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => {
|
||||||
|
callCount++;
|
||||||
|
return makeBody(defaultConfig());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
pollIntervalMs: 50,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
await store.startPolling();
|
||||||
|
|
||||||
|
const countAfterStart = callCount;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 130));
|
||||||
|
|
||||||
|
const countAfterWait = callCount;
|
||||||
|
const intervalFetchCount = countAfterWait - countAfterStart;
|
||||||
|
|
||||||
|
expect(intervalFetchCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(intervalFetchCount).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopPolling prevents further refreshes", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const mockClient = createMockS3Client({
|
||||||
|
getResponse: () => {
|
||||||
|
callCount++;
|
||||||
|
return makeBody(defaultConfig());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
store = createEdgeConfigStore<TestConfig>({
|
||||||
|
s3Key: "admin/test-config.json",
|
||||||
|
schema: TestConfigSchema,
|
||||||
|
defaultValue: defaultConfig,
|
||||||
|
pollIntervalMs: 50,
|
||||||
|
s3Client: mockClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.startPolling();
|
||||||
|
store.stopPolling();
|
||||||
|
|
||||||
|
const countAfterStop = callCount;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
expect(callCount).toBe(countAfterStop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user