-
Notifications
You must be signed in to change notification settings - Fork 13
feat(agent-bff): let a host drop what was read from the SaaS #1874
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/prd-1076-4-base-path
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -68,6 +68,11 @@ export interface BuildBffOptions { | |
|
|
||
| export interface Bff { | ||
| callback: BffCallback; | ||
| /** | ||
| * Forget what was read from the SaaS. A host that knows the schema just moved — an agent | ||
| * restarting on a customization refresh — calls this instead of waiting out the 24h TTL. | ||
| */ | ||
| invalidate(): void; | ||
| } | ||
|
|
||
| const SESSION_TTL_SECONDS = 24 * 60 * 60; | ||
|
|
@@ -318,6 +323,7 @@ function buildAgentRouteMiddlewares( | |
| bundle: ReadModelBundle | undefined, | ||
| config: BFFConfig, | ||
| logger: Logger, | ||
| permissionsCache: PermissionsCache, | ||
| ): Middleware[] { | ||
| if (!bundle) { | ||
| logger( | ||
|
|
@@ -337,7 +343,7 @@ function buildAgentRouteMiddlewares( | |
| forestServerUrl: apiKeyConfig.forestServerUrl, | ||
| envSecret: apiKeyConfig.forestEnvSecret, | ||
| }), | ||
| cache: new PermissionsCache(), | ||
| cache: permissionsCache, | ||
| logger, | ||
| }); | ||
|
|
||
|
|
@@ -381,26 +387,32 @@ function buildAiMiddlewares(config: BFFConfig, oauth: OAuthEdge, logger: Logger) | |
| ]; | ||
| } | ||
|
|
||
| interface AgentEdge { | ||
| middlewares: Middleware[]; | ||
| invalidate(): void; | ||
| } | ||
|
|
||
| function buildAgentMiddlewares( | ||
| config: BFFConfig, | ||
| logger: Logger, | ||
| oauth: OAuthEdge, | ||
| aiMiddlewares: Middleware[], | ||
| basePath: string, | ||
| ): Middleware[] { | ||
| ): AgentEdge { | ||
| const { forestAuthSecret, defaultTimezone } = config; | ||
|
|
||
| if (!forestAuthSecret) { | ||
| logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); | ||
|
|
||
| return []; | ||
| return { middlewares: [], invalidate: () => undefined }; | ||
| } | ||
|
|
||
| const apiKeyStep = buildApiKeyMiddleware(config, logger) ?? createApiKeyUnavailableGuard(logger); | ||
| // One store for the whole edge. The document only unfolds when the agent is reachable too: with no | ||
| // AGENT_URL every data path answers 501, so concrete paths would advertise a dead surface. | ||
| const bundle = resolveReadModelBundle(config, logger); | ||
| const source = toUnfoldSource(bundle, config, logger); | ||
| const permissionsCache = new PermissionsCache(); | ||
|
|
||
| const chain: Middleware[] = [ | ||
| createAuthModeMiddleware({ authSecret: forestAuthSecret }), | ||
|
|
@@ -430,10 +442,19 @@ function buildAgentMiddlewares( | |
| : []), | ||
| ...aiMiddlewares, | ||
| createTimezoneMiddleware({ defaultTimezone }), | ||
| ...buildAgentRouteMiddlewares(bundle, config, logger), | ||
| ...buildAgentRouteMiddlewares(bundle, config, logger, permissionsCache), | ||
| ]; | ||
|
|
||
| return chain.map(agentScoped); | ||
| return { | ||
| middlewares: chain.map(agentScoped), | ||
| invalidate: () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude Opus 5 (claude-opus-5): Should fix An invalidation is a significant state transition and it leaves no trace — no log, no metric — while switching off the one gauge that exists. Worse than absent, the telemetry is misleading: The operator who gets "the schema is wrong" cannot establish that an invalidation happened, when, or whether the refetch that followed succeeded — the first question they will ask, and the only one the telemetry cannot answer. Related, same blind spot: the deliberate One line here for the invalidation itself: logger('Info', 'Dropping the SaaS read caches on host request');
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both halves fixed: |
||
| // The one trace an invalidation leaves: the operator asked "the schema is wrong" needs to | ||
| // establish that one happened and when, and the age gauge cannot say it. | ||
| logger('Info', 'Dropping the SaaS read caches on host request'); | ||
| bundle?.store.invalidate(); | ||
| permissionsCache.clear(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude Opus 5 (claude-opus-5): Must fix Applies to: A restart that revoked an access can leave that user authorised for up to 15 more minutes, and nothing anywhere says so.
The mirror case is just as real: an access just granted can stay blocked for 15 minutes via What makes this a defect rather than a design choice is the asymmetry inside this same commit: Same pattern as the schema cache: a generation counter bumped in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed as you described: |
||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -462,7 +483,8 @@ export default async function buildBff({ | |
|
|
||
| const oauth = buildOAuthMiddlewares(config, logger); | ||
| const aiMiddlewares = buildAiMiddlewares(config, oauth, logger); | ||
| const agentMiddlewares = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares, mountPath); | ||
| const agentEdge = buildAgentMiddlewares(config, logger, oauth, aiMiddlewares, mountPath); | ||
| const agentMiddlewares = agentEdge.middlewares; | ||
| const hasAgentEdge = agentMiddlewares.length > 0; | ||
| const agentErrorMiddleware = hasAgentEdge ? [agentScoped(createErrorMiddleware({ logger }))] : []; | ||
| const agentJsonOnlyGuard = hasAgentEdge ? [agentScoped(createJsonOnlyGuard())] : []; | ||
|
|
@@ -490,5 +512,5 @@ export default async function buildBff({ | |
| const app = new Koa(); | ||
| for (const middleware of middlewares) app.use(middleware); | ||
|
|
||
| return { callback: app.callback() }; | ||
| return { callback: app.callback(), invalidate: agentEdge.invalidate }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,42 @@ | ||
| import type { SchemaFetcher } from './forest-schema-client'; | ||
| import type { Logger } from '../ports/logger-port'; | ||
| import type { Metrics } from '../ports/metrics-port'; | ||
| import type { ForestSchemaCollection } from '@forestadmin/forestadmin-client'; | ||
|
|
||
| import SchemaUnavailableError from './errors'; | ||
|
|
||
| export const ONE_DAY_MS = 24 * 60 * 60 * 1000; | ||
|
|
||
| /** | ||
| * How long after a `clear()` the cache keeps re-reading the schema, and how often. An invalidation | ||
| * says "the agent just changed its schema", but the SaaS the BFF reads from may not have finished | ||
| * recording it: caching whatever comes back on the very next read would pin the old schema for a | ||
| * full day. Within this window a read costs one request every few seconds, which is nothing next to | ||
| * serving a collection the agent exposes and the BFF 404s. | ||
| */ | ||
| export const REVALIDATION_WINDOW_MS = 60 * 1000; | ||
| export const REVALIDATION_TTL_MS = 5 * 1000; | ||
|
|
||
| export const SCHEMA_CACHE_REFRESH_ERROR = 'schema_cache_refresh_error'; | ||
| export const SCHEMA_CACHE_AGE_SECONDS = 'schema_cache_age_seconds'; | ||
|
|
||
| export interface SchemaCacheOptions { | ||
| fetcher: SchemaFetcher; | ||
| metrics: Metrics; | ||
| logger?: Logger; | ||
| now?: () => number; | ||
| ttlMs?: number; | ||
| } | ||
|
|
||
| interface CacheEntry { | ||
| collections: ForestSchemaCollection[]; | ||
| fetchedAt: number; | ||
| /** | ||
| * Decided when the entry is written, not when it is read: a read taken while the revalidation | ||
| * window was open cannot be trusted for a day, and must not be promoted to a long-lived entry the | ||
| * moment the window closes. | ||
| */ | ||
| expiresAt: number; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -30,22 +48,32 @@ interface CacheEntry { | |
| export default class SchemaCache { | ||
| private readonly fetcher: SchemaFetcher; | ||
| private readonly metrics: Metrics; | ||
| private readonly logger: Logger; | ||
| private readonly now: () => number; | ||
| private readonly ttlMs: number; | ||
|
|
||
| private entry: CacheEntry | null = null; | ||
| private inFlight: Promise<ForestSchemaCollection[]> | null = null; | ||
| private revisionValue = 0; | ||
|
|
||
| constructor({ fetcher, metrics, now = Date.now, ttlMs = ONE_DAY_MS }: SchemaCacheOptions) { | ||
| private generation = 0; | ||
| private revalidatingUntil = 0; | ||
|
|
||
| constructor({ | ||
| fetcher, | ||
| metrics, | ||
| logger = () => undefined, | ||
| now = Date.now, | ||
| ttlMs = ONE_DAY_MS, | ||
| }: SchemaCacheOptions) { | ||
| this.fetcher = fetcher; | ||
| this.metrics = metrics; | ||
| this.logger = logger; | ||
| this.now = now; | ||
| this.ttlMs = ttlMs; | ||
| } | ||
|
|
||
| async get(): Promise<ForestSchemaCollection[]> { | ||
| if (this.entry && this.now() - this.entry.fetchedAt < this.ttlMs) { | ||
| if (this.entry && this.now() < this.entry.expiresAt) { | ||
| this.emitAge(); | ||
|
|
||
| return this.entry.collections; | ||
|
|
@@ -54,6 +82,23 @@ export default class SchemaCache { | |
| return this.refresh(); | ||
| } | ||
|
|
||
| /** | ||
| * Expire what is cached and re-read it eagerly for a while. Called when something outside knows | ||
| * the schema moved — an agent restarting on a customization refresh, say. | ||
| * | ||
| * The entry is expired, not dropped: every read now goes through a refresh, so nothing the host | ||
| * declared stale is served while the SaaS answers, but the class keeps its safety net — a refresh | ||
| * that fails still has a last good schema to fall back on. Dropping it would turn a restart | ||
| * during a SaaS blip into a `503 schema_unavailable` on every data route. | ||
| */ | ||
| clear(): void { | ||
| this.generation += 1; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude Opus 5 (claude-opus-5): Should fix Applies to: A read that arrives strictly after
B is a request the host would expect to see the new schema, since it arrived after the invalidation completed. The symptom is the one the feature exists to remove — a collection the restarted agent now exposes still 404s. Stamp the in-flight promise with the generation that created it and start a fresh fetch when it no longer matches. Untested:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: |
||
| this.inFlight = null; | ||
| this.revalidatingUntil = this.now() + REVALIDATION_WINDOW_MS; | ||
|
|
||
| if (this.entry) this.entry = { ...this.entry, expiresAt: this.now() }; | ||
| } | ||
|
|
||
| ageSeconds(): number | undefined { | ||
| if (!this.entry) return undefined; | ||
|
|
||
|
|
@@ -66,15 +111,21 @@ export default class SchemaCache { | |
|
|
||
| private async refresh(): Promise<ForestSchemaCollection[]> { | ||
| if (!this.inFlight) { | ||
| this.inFlight = this.doRefresh().finally(() => { | ||
| this.inFlight = null; | ||
| // Identity-guarded, because `clear()` detaches the in-flight fetch: a read that lands after an | ||
| // invalidation must start its own, not join the one that read the invalidated schema. | ||
| const pending: Promise<ForestSchemaCollection[]> = this.doRefresh().finally(() => { | ||
| if (this.inFlight === pending) this.inFlight = null; | ||
| }); | ||
|
|
||
| this.inFlight = pending; | ||
| } | ||
|
|
||
| return this.inFlight; | ||
| } | ||
|
|
||
| private async doRefresh(): Promise<ForestSchemaCollection[]> { | ||
| const { generation } = this; | ||
|
|
||
| try { | ||
| const collections = await this.fetcher.fetchSchema(); | ||
|
|
||
|
|
@@ -83,16 +134,29 @@ export default class SchemaCache { | |
| // failed fetch and fall through to the failure path below. | ||
| if (collections.length === 0) throw new Error('Forest returned an empty schema'); | ||
|
|
||
| this.entry = { collections, fetchedAt: this.now() }; | ||
| this.revisionValue += 1; | ||
| this.emitAge(); | ||
| // Skip the write if a clear() happened while this fetch was in flight: it read the schema the | ||
| // invalidation declared stale, and caching it now would undo the invalidation. | ||
| if (this.generation === generation) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude Opus 5 (claude-opus-5): Should fix Applies to: This PR introduces a generation change that leaves Because It self-heals on the next request, so this is not perpetuating, but the documented invariant is broken for that request and the comment that guarantees it is now wrong. That comment's reasoning assumed the only source of a dropped write was a refresh, which always bumps the revision; Shortest honest fix: have
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed your way: |
||
| const fetchedAt = this.now(); | ||
|
|
||
| this.entry = { collections, fetchedAt, expiresAt: fetchedAt + this.ttlFor(fetchedAt) }; | ||
| this.revisionValue += 1; | ||
| this.emitAge(); | ||
| } | ||
|
|
||
| return collections; | ||
| } catch (error) { | ||
| this.metrics.increment(SCHEMA_CACHE_REFRESH_ERROR); | ||
| // The counter alone cannot tell "the SaaS returned an empty array" from "the SaaS is down" | ||
| // from "the env secret is wrong", and the cause is otherwise swallowed: it becomes the `cause` | ||
| // of a `SchemaUnavailableError` the error middleware serialises without logging. | ||
| this.logger('Warn', 'Schema refresh failed', { | ||
| cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error), | ||
| servedStale: this.entry !== null, | ||
| }); | ||
|
|
||
| // Warm cache: keep serving the last good schema (stale), do not poison — the next read | ||
| // re-attempts because `fetchedAt` is unchanged and the entry stays expired. | ||
| // re-attempts because `expiresAt` is unchanged and the entry stays expired. | ||
| if (this.entry) { | ||
| this.emitAge(); | ||
|
|
||
|
|
@@ -104,6 +168,10 @@ export default class SchemaCache { | |
| } | ||
| } | ||
|
|
||
| private ttlFor(fetchedAt: number): number { | ||
| return fetchedAt < this.revalidatingUntil ? REVALIDATION_TTL_MS : this.ttlMs; | ||
| } | ||
|
|
||
| private emitAge(): void { | ||
| const age = this.ageSeconds(); | ||
| if (age !== undefined) this.metrics.gauge(SCHEMA_CACHE_AGE_SECONDS, age); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude Opus 5 (claude-opus-5): Violates conventions —
skills/conventions/testing.md#Cover error and edge paths, not only the happy pathThe functional branch of
invalidate()has no test. The one added case exercises the other branch — the no-op returned when the agent edge is not mounted — and asserts onlynot.toThrow(). Nothing anywhere asserts that on a mounted edgeinvalidate()actually reachesstore.invalidate()andpermissionsCache.clear().So the wiring this PR restructured is uncovered: swap
permissionsCache.clear()for a no-op, or revert the hoistedPermissionsCacheback to one constructed insidebuildAgentRouteMiddlewares, and the suite stays green — while every host that callsinvalidate()keeps serving permission decisions from before the invalidation until the 15-minute TTL expires. That the cleared instance is the same one handed to the route middlewares is the load-bearing fact of the change, and it is exactly what no test pins.The second no-op branch is uncovered too: with
FOREST_AUTH_SECRETpresent but the read-model bundle absent,bundle?.store.invalidate()is a silent no-op and only the permissions clear runs.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed:
test/build-bff-invalidate.test.tscaptures whatcreatePermissionsRoutesMiddlewarewas handed and assertsinvalidate()empties that exact cache and invalidates that exact store, plus theFOREST_AUTH_SECRET-present-but-no-bundle branch (no permissions route mounted, still safe to invalidate) and the log line. Both mutations you named now fail the suite.