perf(statuses): align stack-status cache TTL and invalidation with polling (#1814)

The 3s stack-statuses cache TTL never survived the 10s dashboard poll, so
every ordinary poll recomputed. Raise the TTL to 15s and move the git-source
label and self-identity enrichment inside the cached payload so cache hits
serve fully decorated statuses with zero per-request work.

Invalidation closes the gaps the longer TTL would otherwise widen:

- DockerEventService drops stack-statuses:<nodeId> on container state events
  so the UI's state-invalidate refetch recomputes instead of hitting a stale
  entry. The narrow key only: container events do not reshape stack identity
  or file roots, and the stats key self-refreshes on its own 2s TTL.
- git-source link and unlink invalidate node caches before responding, so the
  source label stays fresh without waiting for the TTL.
- a payload whose enrichment degraded (identity probe failure or git-source
  scan failure) is never cached, so a mislabeled not-self or 'local' badge
  cannot persist for a full TTL window. Running outside Docker is not
  degradation, so host installs cache normally.
This commit is contained in:
Anso
2026-08-09 23:13:59 -04:00
committed by GitHub
parent 9798700401
commit 55ca82abb2
10 changed files with 362 additions and 96 deletions
+5 -1
View File
@@ -44,4 +44,8 @@ export const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
// Keys are per-node: "stats:<nodeId>", "system-stats:<nodeId>", "stack-statuses:<nodeId>".
export const STATS_CACHE_TTL_MS = 2_000;
export const SYSTEM_STATS_CACHE_TTL_MS = 3_000;
export const STACK_STATUSES_CACHE_TTL_MS = 3_000;
// Stack statuses are cached past the frontend dashboard poll cadence (10s),
// so ordinary polls hit instead of recomputing. Container events and
// lifecycle mutations invalidate the key; 15s bounds worst-case staleness for
// any missed invalidation path.
export const STACK_STATUSES_CACHE_TTL_MS = 15_000;
+51 -27
View File
@@ -50,19 +50,18 @@ function workingDirMatchesStack(workingDir: string | undefined, stackName: strin
}
async function getRunningContainerLabels(): Promise<Record<string, string> | null> {
try {
const runtimeIds = await getRuntimeContainerIdCandidates();
if (runtimeIds.length === 0) return null;
const runtimeIds = await getRuntimeContainerIdCandidates();
if (runtimeIds.length === 0) return null;
const containers = await DockerController.getInstance().getDocker().listContainers({ all: true }) as ListedContainer[];
const selfContainer = containers.find((container) => {
const containerId = container.Id;
return typeof containerId === 'string' && runtimeIds.some(id => matchesContainerId(containerId, id));
});
return selfContainer?.Labels ?? null;
} catch {
return null;
}
// A listContainers failure (Docker socket unreachable) propagates so
// resolveSelfStackIdentity can mark the resolution degraded. Callers that
// need null-on-failure wrap this call themselves.
const containers = await DockerController.getInstance().getDocker().listContainers({ all: true }) as ListedContainer[];
const selfContainer = containers.find((container) => {
const containerId = container.Id;
return typeof containerId === 'string' && runtimeIds.some(id => matchesContainerId(containerId, id));
});
return selfContainer?.Labels ?? null;
}
async function runningContainerMatchesStack(stackName: string, composeDir?: string): Promise<boolean> {
@@ -89,10 +88,14 @@ export async function getSelfStackProjectName(): Promise<string | null> {
/** Directory name of the running Sencho compose project, when it is under COMPOSE_DIR. */
export async function getSelfStackDirectoryName(composeDir?: string): Promise<string | null> {
const labels = await getRunningContainerLabels();
const workingDirStack = stackNameFromWorkingDir(labels?.['com.docker.compose.project.working_dir'], composeDir);
if (workingDirStack) return workingDirStack;
return getSelfStackProjectName();
try {
const labels = await getRunningContainerLabels();
const workingDirStack = stackNameFromWorkingDir(labels?.['com.docker.compose.project.working_dir'], composeDir);
if (workingDirStack) return workingDirStack;
return getSelfStackProjectName();
} catch {
return null;
}
}
/** True when the stack appears to be the running Sencho compose project. */
@@ -117,25 +120,46 @@ export async function isSelfStack(stackName: string, composeDir?: string): Promi
export interface SelfStackIdentity {
projectName: string | null;
labels: Record<string, string> | null;
/**
* True when the container-labels probe failed (Docker socket unreachable).
* A degraded identity cannot be trusted to classify every stack correctly,
* so it must not be cached: the next request should re-resolve. Running
* outside Docker is NOT degraded: both sources legitimately resolve to
* null there and the identity is correct as-is.
*/
degraded: boolean;
}
/**
* Resolves both identity sources. Each failure degrades only its own
* source to null, matching the old per-stack behavior where a labels
* failure never discarded the resolved project name.
* Resolves both identity sources. A labels-probe failure degrades only that
* source to null and marks the resolution degraded, matching the old
* per-stack behavior where a labels failure never discarded the resolved
* project name. getSelfStackProjectName swallows its own failures and
* returns null, so it cannot mark the identity degraded.
*/
export async function resolveSelfStackIdentity(): Promise<SelfStackIdentity> {
const projectName = await getSelfStackProjectName().catch((error) => {
console.error('Failed to resolve self-stack project name; self-stack check degraded:', error);
return null;
});
const labels = await getRunningContainerLabels().catch((error) => {
const projectName = await getSelfStackProjectName();
let labels: Record<string, string> | null = null;
let degraded = false;
try {
labels = await getRunningContainerLabels();
} catch (error) {
console.error('Failed to resolve self-stack container labels; self-stack check degraded:', error);
return null;
});
return { projectName, labels };
degraded = true;
}
return { projectName, labels, degraded };
}
/**
* Identity used when no resolution is attempted (empty fleet). Not degraded:
* with no stacks there is nothing to mislabel, so the payload caches as-is.
*/
export const UNRESOLVED_SELF_STACK_IDENTITY: SelfStackIdentity = {
projectName: null,
labels: null,
degraded: false,
};
/** isSelf semantics identical to isSelfStack() when the identity resolves successfully. */
export function isSelfStackByIdentity(identity: SelfStackIdentity, stackName: string, composeDir?: string): boolean {
if (identity.projectName === stackName) return true;