Skip to content

Commit bcc4a03

Browse files
Add opportunistic inline-script TTL eviction
Run a best-effort 14-day cache sweep once per manager session before the first inline-script environment creation. Reuse the existing safe deletion and association cleanup paths while preserving uncertain or active entries. Part of #1602. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c8d26d9 commit bcc4a03

2 files changed

Lines changed: 454 additions & 24 deletions

File tree

‎src/managers/builtin/inlineScript/envManager.ts‎

Lines changed: 210 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { getErrorMessage } from '../../../common/errors/utils';
2626
import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey';
2727
import {
28+
CacheEntrySummary,
2829
CacheEnvironmentInspection,
2930
INLINE_SCRIPT_CACHE_DIR_NAME,
3031
InlineScriptEnvMeta,
@@ -38,6 +39,7 @@ import {
3839
inspectMetaJson,
3940
restoreMetaJsonBackupUnderLock,
4041
resolveCacheEntryPath,
42+
selectStaleEntries,
4143
writeMetaJson,
4244
} from '../../../common/inlineScript/cacheLayout';
4345
import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter';
@@ -89,6 +91,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
8991

9092
const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
9193
const CACHE_LOCK_RETRY_MS = 500;
94+
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
9295
const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000;
9396
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000, 30_000] as const;
9497
const PERSISTED_ASSOCIATION_SCHEMA_VERSION = 1 as const;
@@ -144,6 +147,12 @@ interface MergeCacheEntrySourceMetadataIdentityHashResult {
144147
readonly sourceMetadataIdentityHashes?: readonly string[];
145148
}
146149

150+
interface CacheEntryRemovalOptions {
151+
readonly shouldRemove?: (entryPath: string) => Promise<boolean>;
152+
readonly afterRemove?: () => void;
153+
readonly reclaimRetainedLock?: boolean;
154+
}
155+
147156
type CacheEntryInspection =
148157
| { readonly kind: 'absent' | 'stale' | 'uncertain' }
149158
| { readonly kind: 'reusable'; readonly environment: PythonEnvironment };
@@ -200,6 +209,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
200209
private cacheMaintenanceBarrier: Deferred<void> | undefined;
201210
private pendingCacheMaintenances = 0;
202211
private activeCreateOperations = 0;
212+
private ttlEviction: Promise<void> | undefined;
213+
private cacheMutationRevision = 0;
203214
private disposed = false;
204215

205216
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
@@ -260,6 +271,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
260271
): Promise<PythonEnvironment | undefined> {
261272
this.activeCreateOperations += 1;
262273
try {
274+
await this.runTtlEvictionOnce();
263275
return await this.waitForCacheMaintenance(async () => {
264276
try {
265277
const scriptUri = this.getScriptUri(scope);
@@ -583,6 +595,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
583595
}
584596

585597
private async refreshDiscoveredEnvironments(checkForSnapshotChanges: boolean): Promise<boolean> {
598+
const cacheMaintenance = this.cacheMaintenanceBarrier;
599+
if (cacheMaintenance) {
600+
await cacheMaintenance.promise;
601+
}
602+
const cacheMutationRevision = this.cacheMutationRevision;
586603
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
587604
const previousByKey = new Map(
588605
this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]),
@@ -699,6 +716,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
699716
if (this.disposed) {
700717
return false;
701718
}
719+
if (cacheMutationRevision !== this.cacheMutationRevision) {
720+
return true;
721+
}
702722

703723
// Preserve previously known entries when a refresh cannot safely classify
704724
// them because a build is in progress or the filesystem is transiently unavailable.
@@ -2139,6 +2159,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
21392159
return this.associationStore.clear();
21402160
}
21412161

2162+
private runTtlEvictionOnce(): Promise<void> {
2163+
if (!this.ttlEviction) {
2164+
this.ttlEviction = this.enqueueCacheMaintenance(() =>
2165+
this.enqueueSelection(() => this.evictStaleCacheEntries()),
2166+
).catch((error) => {
2167+
this.log.warn(`Unable to evict stale inline-script environments: ${getErrorMessage(error)}`);
2168+
});
2169+
}
2170+
return this.ttlEviction;
2171+
}
2172+
21422173
private async waitForCacheMaintenance<T>(operation: () => Promise<T>): Promise<T> {
21432174
const barrier = this.cacheMaintenanceBarrier;
21442175
if (barrier) {
@@ -2849,6 +2880,142 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
28492880
return { environment: result.environment };
28502881
}
28512882

2883+
private async evictStaleCacheEntries(): Promise<void> {
2884+
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
2885+
const physicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot);
2886+
if (!physicalCacheRootPath) {
2887+
return;
2888+
}
2889+
2890+
let entryNames: string[];
2891+
try {
2892+
entryNames = await fs.readdir(physicalCacheRootPath);
2893+
} catch (error) {
2894+
if (isFileNotFoundError(error)) {
2895+
return;
2896+
}
2897+
throw error;
2898+
}
2899+
2900+
const now = new Date();
2901+
const entries: CacheEntrySummary[] = [];
2902+
for (const entryName of entryNames.sort()) {
2903+
if (entryName.endsWith(FILE_LOCK_DIR_SUFFIX)) {
2904+
continue;
2905+
}
2906+
const entryPath = path.join(physicalCacheRootPath, entryName);
2907+
try {
2908+
const stat = await fs.lstat(entryPath);
2909+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
2910+
continue;
2911+
}
2912+
const sidecar = await inspectMetaJson(Uri.file(entryPath));
2913+
if (sidecar.kind === 'valid') {
2914+
entries.push({
2915+
envDirPath: entryPath,
2916+
lastUsedAt: new Date(sidecar.metadata.lastUsedAt),
2917+
});
2918+
}
2919+
} catch (error) {
2920+
if (!isFileNotFoundError(error)) {
2921+
this.log.warn(
2922+
`Unable to inspect inline-script cache entry for TTL eviction ${entryPath}: ${getErrorMessage(error)}`,
2923+
);
2924+
}
2925+
}
2926+
}
2927+
2928+
const staleEntries = selectStaleEntries(entries, now, CACHE_TTL_MS);
2929+
if (staleEntries.length === 0) {
2930+
return;
2931+
}
2932+
2933+
const persistedAssociations = await this.getPersistedAssociationSnapshot();
2934+
const scriptPaths = this.getTrackedScriptPaths(persistedAssociations);
2935+
const priorSelections = this.getPriorSelections(scriptPaths);
2936+
const removedCacheEntries = new Set<string>();
2937+
for (const staleEntry of staleEntries) {
2938+
try {
2939+
const removed = await this.removeCacheEntryForClear(
2940+
cacheRoot,
2941+
physicalCacheRootPath,
2942+
path.basename(staleEntry),
2943+
{
2944+
reclaimRetainedLock: false,
2945+
afterRemove: () => {
2946+
this.cacheMutationRevision += 1;
2947+
},
2948+
shouldRemove: async (entryPath) => {
2949+
const sidecar = await inspectMetaJson(Uri.file(entryPath));
2950+
return (
2951+
sidecar.kind === 'valid' &&
2952+
selectStaleEntries(
2953+
[
2954+
{
2955+
envDirPath: entryPath,
2956+
lastUsedAt: new Date(sidecar.metadata.lastUsedAt),
2957+
},
2958+
],
2959+
now,
2960+
CACHE_TTL_MS,
2961+
).length === 1
2962+
);
2963+
},
2964+
},
2965+
);
2966+
if (removed) {
2967+
removedCacheEntries.add(normalizePath(removed));
2968+
} else if (await this.isCacheEntryDefinitelyMissing(staleEntry)) {
2969+
this.cacheMutationRevision += 1;
2970+
removedCacheEntries.add(normalizePath(staleEntry));
2971+
}
2972+
} catch (error) {
2973+
this.log.warn(
2974+
`Unable to evict stale inline-script cache entry ${staleEntry}: ${getErrorMessage(error)}`,
2975+
);
2976+
if (await this.isCacheEntryDefinitelyMissing(staleEntry)) {
2977+
this.cacheMutationRevision += 1;
2978+
removedCacheEntries.add(normalizePath(staleEntry));
2979+
}
2980+
}
2981+
}
2982+
2983+
if (removedCacheEntries.size === 0) {
2984+
return;
2985+
}
2986+
2987+
this.replaceDiscoveredEnvironments(
2988+
this.collection.filter(
2989+
(environment) => !removedCacheEntries.has(normalizePath(environment.sysPrefix)),
2990+
),
2991+
);
2992+
const invalidatedScriptPaths = await this.getInvalidatedAssociationPaths(
2993+
scriptPaths,
2994+
persistedAssociations,
2995+
removedCacheEntries,
2996+
);
2997+
await this.clearInvalidatedAssociations(
2998+
invalidatedScriptPaths,
2999+
persistedAssociations,
3000+
priorSelections,
3001+
);
3002+
}
3003+
3004+
private async isCacheEntryDefinitelyMissing(entryPath: string): Promise<boolean> {
3005+
try {
3006+
await fs.lstat(entryPath);
3007+
return false;
3008+
} catch (error) {
3009+
if (isFileNotFoundError(error)) {
3010+
return true;
3011+
}
3012+
this.log.warn(
3013+
`Unable to verify stale inline-script cache entry ${entryPath}: ${getErrorMessage(error)}`,
3014+
);
3015+
return false;
3016+
}
3017+
}
3018+
28523019
private async clearCacheInternal(activeCreatesAtStart: number): Promise<void> {
28533020
if (activeCreatesAtStart > 0) {
28543021
const message = l10n.t(
@@ -2861,21 +3028,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
28613028
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
28623029
const physicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot);
28633030
const persistedAssociations = await this.getPersistedAssociationSnapshot();
2864-
const scriptPaths = new Set<string>([
2865-
...Object.keys(persistedAssociations),
2866-
...this.associationRevisions.keys(),
2867-
...this.cachedAssociationValidatedAt.keys(),
2868-
...this.lastValidatedMetadataIdentities.keys(),
2869-
...this.lastValidatedMetadataIdentityProofs.keys(),
2870-
...this.fsPathToEnv.keys(),
2871-
...this.fsPathToPersistedAssociation.keys(),
2872-
...this.pendingRehydrations.keys(),
2873-
...this.pendingMetadataRefreshes.keys(),
2874-
]);
2875-
const priorSelections = new Map<string, PythonEnvironment | undefined>();
2876-
scriptPaths.forEach((scriptPath) => {
2877-
priorSelections.set(scriptPath, this.fsPathToEnv.get(scriptPath));
2878-
});
3031+
const scriptPaths = this.getTrackedScriptPaths(persistedAssociations);
3032+
const priorSelections = this.getPriorSelections(scriptPaths);
28793033

28803034
const removedCacheEntries = new Set<string>();
28813035
const deletionErrors: unknown[] = [];
@@ -2953,11 +3107,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29533107
cacheRoot: Uri,
29543108
originalPhysicalCacheRootPath: string,
29553109
entryName: string,
3110+
options: CacheEntryRemovalOptions = {},
29563111
): Promise<string | undefined> {
29573112
const envDirPath = path.join(originalPhysicalCacheRootPath, entryName);
29583113
let lock: AcquiredFileLock | undefined;
29593114
try {
2960-
lock = await this.acquireCacheEntryLockForClear(envDirPath);
3115+
lock = await this.acquireCacheEntryLockForClear(
3116+
envDirPath,
3117+
options.reclaimRetainedLock !== false,
3118+
);
29613119
const currentPhysicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot);
29623120
if (!currentPhysicalCacheRootPath) {
29633121
return undefined;
@@ -2981,7 +3139,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29813139
if (!entryPath) {
29823140
return undefined;
29833141
}
3142+
if (options.shouldRemove && !(await options.shouldRemove(entryPath))) {
3143+
return undefined;
3144+
}
29843145
await this.deleteCacheEntryForClear(entryPath);
3146+
options.afterRemove?.();
29853147
return entryPath;
29863148
} finally {
29873149
if (lock) {
@@ -2990,7 +3152,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29903152
}
29913153
}
29923154

2993-
private async acquireCacheEntryLockForClear(envDirPath: string): Promise<AcquiredFileLock> {
3155+
private async acquireCacheEntryLockForClear(
3156+
envDirPath: string,
3157+
reclaimRetainedLock: boolean = true,
3158+
): Promise<AcquiredFileLock> {
29943159
for (let attempt = 0; attempt < 3; attempt += 1) {
29953160
try {
29963161
return await acquireFileLock(envDirPath, { timeoutMs: 0, retryIntervalMs: CACHE_LOCK_RETRY_MS });
@@ -2999,10 +3164,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29993164
throw error;
30003165
}
30013166
const lockState = await inspectFileLock(envDirPath);
3002-
if (lockState === 'stale' || lockState === 'retained') {
3167+
if (lockState === 'stale' || (lockState === 'retained' && reclaimRetainedLock)) {
30033168
await reclaimFileLock(envDirPath);
30043169
continue;
30053170
}
3171+
if (lockState === 'retained') {
3172+
throw error;
3173+
}
30063174
if (lockState === 'missing') {
30073175
continue;
30083176
}
@@ -3260,6 +3428,30 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
32603428
return this.parsePersistedAssociations(await this.associationStore.read<unknown>())?.records ?? {};
32613429
}
32623430

3431+
private getTrackedScriptPaths(
3432+
persistedAssociations: PersistedInlineScriptEnvironments,
3433+
): Set<string> {
3434+
return new Set([
3435+
...Object.keys(persistedAssociations),
3436+
...this.associationRevisions.keys(),
3437+
...this.cachedAssociationValidatedAt.keys(),
3438+
...this.lastValidatedMetadataIdentities.keys(),
3439+
...this.lastValidatedMetadataIdentityProofs.keys(),
3440+
...this.fsPathToEnv.keys(),
3441+
...this.fsPathToPersistedAssociation.keys(),
3442+
...this.pendingRehydrations.keys(),
3443+
...this.pendingMetadataRefreshes.keys(),
3444+
]);
3445+
}
3446+
3447+
private getPriorSelections(
3448+
scriptPaths: ReadonlySet<string>,
3449+
): Map<string, PythonEnvironment | undefined> {
3450+
return new Map(
3451+
Array.from(scriptPaths, (scriptPath) => [scriptPath, this.fsPathToEnv.get(scriptPath)]),
3452+
);
3453+
}
3454+
32633455
private async removeCacheEntry(envDir: Uri): Promise<boolean> {
32643456
try {
32653457
await fs.remove(envDir.fsPath);

0 commit comments

Comments
 (0)