addons/isl-server/src/RepositoryCache.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {AbsolutePath, RepositoryError, ValidatedRepoInfo} from 'isl/src/types';
b69ab319import type {RepositoryContext} from './serverTypes';
b69ab3110
b69ab3111import {TypedEventEmitter} from 'shared/TypedEventEmitter';
b69ab3112import {ensureTrailingPathSep} from 'shared/pathUtils';
b69ab3113import {Repository} from './Repository';
b69ab3114
b69ab3115/**
b69ab3116 * Reference-counting access to a {@link Repository}, via a Promise.
b69ab3117 * Be sure to `unref()` when no longer needed.
b69ab3118 */
b69ab3119export interface RepositoryReference {
b69ab3120 promise: Promise<Repository | RepositoryError>;
b69ab3121 unref: () => unknown;
b69ab3122}
b69ab3123
b69ab3124/**
b69ab3125 * We return `RepositoryReference`s synchronously before we have the Repository,
b69ab3126 * but reference counts should be associated with the actual async constructed Repository,
b69ab3127 * which is why we can't return RefCounted<Repository> directly.
b69ab3128 */
b69ab3129class RepositoryReferenceImpl implements RepositoryReference {
b69ab3130 constructor(
b69ab3131 public promise: Promise<Repository | RepositoryError>,
b69ab3132 private disposeFunc: () => void,
b69ab3133 ) {}
b69ab3134 public unref() {
b69ab3135 if (!this.disposed) {
b69ab3136 this.disposed = true;
b69ab3137 this.disposeFunc();
b69ab3138 }
b69ab3139 }
b69ab3140
b69ab3141 internalReference: RefCounted<Repository> | undefined;
b69ab3142 disposed = false;
b69ab3143}
b69ab3144
b69ab3145class RefCounted<T extends {dispose: () => void}> {
b69ab3146 constructor(public value: T) {}
b69ab3147 private references = 0;
b69ab3148
b69ab3149 public isDisposed = false;
b69ab3150 public ref() {
b69ab3151 this.references++;
b69ab3152 }
b69ab3153 public getNumberOfReferences() {
b69ab3154 return this.references;
b69ab3155 }
b69ab3156 public dispose() {
b69ab3157 this.references--;
b69ab3158 if (!this.isDisposed && this.references === 0) {
b69ab3159 this.isDisposed = true;
b69ab3160 this.value.dispose();
b69ab3161 }
b69ab3162 }
b69ab3163}
b69ab3164
b69ab3165class RepoMap {
b69ab3166 /**
b69ab3167 * Previously distributed RepositoryReferences, keyed by repository root path
b69ab3168 * Note that Repositories do not define their own `cwd`, and can be reused across cwds.
b69ab3169 */
b69ab3170 private reposByRoot = new Map<AbsolutePath, RefCounted<Repository>>();
b69ab3171
b69ab3172 /**
b69ab3173 * Ordered by length from longest to shortest, for quick longest prefix match.
b69ab3174 * Used with reposByRoot to achieve O(n) prefix lookup.
b69ab3175 */
b69ab3176 private orderedRoots: string[] = [];
b69ab3177
b69ab3178 private static cmpRoots = (a: string, b: string) => b.length - a.length;
b69ab3179
b69ab3180 public get(repoRoot: AbsolutePath): RefCounted<Repository> | undefined {
b69ab3181 return this.reposByRoot.get(repoRoot);
b69ab3182 }
b69ab3183
b69ab3184 public getLongestPrefixMatch(path: AbsolutePath): RefCounted<Repository> | undefined {
b69ab3185 for (const repoRoot of this.orderedRoots) {
b69ab3186 if (path === repoRoot || path.startsWith(ensureTrailingPathSep(repoRoot))) {
b69ab3187 return this.reposByRoot.get(repoRoot);
b69ab3188 }
b69ab3189 }
b69ab3190 return undefined;
b69ab3191 }
b69ab3192
b69ab3193 public set(repoRoot: AbsolutePath, repoRef: RefCounted<Repository>) {
b69ab3194 this.reposByRoot.set(repoRoot, repoRef);
b69ab3195 this.orderedRoots.push(repoRoot);
b69ab3196 this.orderedRoots.sort(RepoMap.cmpRoots);
b69ab3197 }
b69ab3198
b69ab3199 public values(): IterableIterator<RefCounted<Repository>> {
b69ab31100 return this.reposByRoot.values();
b69ab31101 }
b69ab31102
b69ab31103 public forEach(callback: (value: RefCounted<Repository>) => void) {
b69ab31104 this.reposByRoot.forEach(callback);
b69ab31105 }
b69ab31106}
b69ab31107
b69ab31108/**
b69ab31109 * Allow reusing Repository instances by storing instances by path,
b69ab31110 * and controlling how Repositories are created.
b69ab31111 *
b69ab31112 * Some async work is needed to construct repositories (finding repo root dir),
b69ab31113 * so it's possible to duplicate some work if multiple repos are constructed at similar times.
b69ab31114 * We still enable Repository reuse in this case by double checking for pre-existing Repos at the last second.
b69ab31115 */
b69ab31116class RepositoryCache {
b69ab31117 // allow mocking Repository in tests
b69ab31118 constructor(private RepositoryType = Repository) {}
b69ab31119
b69ab31120 private repoMap = new RepoMap();
b69ab31121 private activeReposEmitter = new TypedEventEmitter<'change', undefined>();
b69ab31122
b69ab31123 private lookup(dirGuess: AbsolutePath): RefCounted<Repository> | undefined {
b69ab31124 const found = this.repoMap.get(dirGuess);
b69ab31125 return found && !found.isDisposed ? found : undefined;
b69ab31126 }
b69ab31127
b69ab31128 // Longest match is necessary because repos can be nested (as submodules).
b69ab31129 private lookupByPrefix(dirGuess: AbsolutePath): RefCounted<Repository> | undefined {
b69ab31130 const found = this.repoMap.getLongestPrefixMatch(dirGuess);
b69ab31131 return found && !found.isDisposed ? found : undefined;
b69ab31132 }
b69ab31133
b69ab31134 /**
b69ab31135 * Create a new Repository, or reuse if one already exists.
b69ab31136 * Repositories are reference-counted to ensure they can be disposed when no longer needed.
b69ab31137 */
b69ab31138 getOrCreate(ctx: RepositoryContext): RepositoryReference {
b69ab31139 // Fast path: if this cwd is already a known repo root, we can use it directly.
b69ab31140 // This only works if the cwd happens to be the repo root.
b69ab31141 const found = this.lookup(ctx.cwd);
b69ab31142 if (found) {
b69ab31143 found.ref();
b69ab31144 return new RepositoryReferenceImpl(Promise.resolve(found.value), () => found.dispose());
b69ab31145 }
b69ab31146
b69ab31147 // More typically, we can reuse a Repository among different cwds:
b69ab31148
b69ab31149 // eslint-disable-next-line prefer-const
b69ab31150 let ref: RepositoryReferenceImpl;
b69ab31151 const lookupRepoInfoAndReuseIfPossible = async (): Promise<Repository | RepositoryError> => {
b69ab31152 // TODO: we should rate limit how many getRepoInfos we run at a time, and make other callers just wait.
b69ab31153 // this would guard against querying lots of redundant paths within the same repo.
b69ab31154 // This is probably not necessary right now, but would be useful for a VS Code extension where we need to query
b69ab31155 // individual file paths to add diff gutters.
b69ab31156 const repoInfo = await this.RepositoryType.getRepoInfo(ctx);
b69ab31157 // important: there should be no `await` points after here, to ensure there is no race when reusing Repositories.
b69ab31158 if (repoInfo.type !== 'success') {
b69ab31159 // No repository found at this root, or some other error prevents the repo from being created
b69ab31160 return repoInfo;
b69ab31161 }
b69ab31162
b69ab31163 if (ref.disposed) {
b69ab31164 // If the reference is disposed, the caller gave up while waiting for the repo to be created.
b69ab31165 // make sure we don't create a dangling Repository.
b69ab31166 return {type: 'unknownError', error: new Error('Repository already disposed')};
b69ab31167 }
b69ab31168
b69ab31169 // Now that we've spent some async time to determine this repo's actual root,
b69ab31170 // we can check if we already have a reference to it saved.
b69ab31171 // This way, we can still reuse a Repository, and only risk duplicating `getRepoInfo` work.
b69ab31172 const newlyFound = this.lookup(repoInfo.repoRoot);
b69ab31173 if (newlyFound) {
b69ab31174 // if it is found now, it means either the cwd differs from the repo root (lookup fails), or
b69ab31175 // another instance was created at the same time and finished faster than this one (lookup failed before, but succeeds now).
b69ab31176
b69ab31177 newlyFound.ref();
b69ab31178 ref.internalReference = newlyFound;
b69ab31179 return newlyFound.value;
b69ab31180 }
b69ab31181
b69ab31182 // This is where we actually start new subscriptions and trigger work, so we should only do this
b69ab31183 // once we're sure we don't have a repository to reuse.
b69ab31184 const repo = new this.RepositoryType(
b69ab31185 repoInfo as ValidatedRepoInfo, // repoInfo is now guaranteed to have these root/dotdir set
b69ab31186 ctx,
b69ab31187 );
b69ab31188
b69ab31189 const internalRef = new RefCounted(repo);
b69ab31190 internalRef.ref();
b69ab31191 ref.internalReference = internalRef;
b69ab31192 this.repoMap.set(repoInfo.repoRoot, internalRef);
b69ab31193 this.activeReposEmitter.emit('change');
b69ab31194 return repo;
b69ab31195 };
b69ab31196 ref = new RepositoryReferenceImpl(lookupRepoInfoAndReuseIfPossible(), () => {
b69ab31197 if (ref.internalReference) {
b69ab31198 ref.internalReference.dispose();
b69ab31199 }
b69ab31200 ref.unref();
b69ab31201 });
b69ab31202 return ref;
b69ab31203 }
b69ab31204
b69ab31205 /**
b69ab31206 * Lookup a cached repository without creating a new one if it doesn't exist
b69ab31207 */
b69ab31208 public cachedRepositoryForPath(path: AbsolutePath): Repository | undefined {
b69ab31209 const ref = this.lookupByPrefix(path);
b69ab31210 return ref?.value;
b69ab31211 }
b69ab31212
b69ab31213 public onChangeActiveRepos(cb: (repos: Array<Repository>) => unknown): () => unknown {
b69ab31214 const onChange = () => {
b69ab31215 cb([...this.repoMap.values()].map(ref => ref.value));
b69ab31216 };
b69ab31217 this.activeReposEmitter.on('change', onChange);
b69ab31218 // start with initial repos set
b69ab31219 onChange();
b69ab31220 return () => this.activeReposEmitter.off('change', onChange);
b69ab31221 }
b69ab31222
b69ab31223 /** Clean up all known repos. Mostly useful for testing. */
b69ab31224 clearCache() {
b69ab31225 this.repoMap.forEach(repo => repo.dispose());
b69ab31226 this.repoMap = new RepoMap();
b69ab31227 this.activeReposEmitter.removeAllListeners();
b69ab31228 }
b69ab31229
b69ab31230 public numberOfActiveServers(): number {
b69ab31231 let numActive = 0;
b69ab31232 for (const repo of this.repoMap.values()) {
b69ab31233 numActive += repo.getNumberOfReferences();
b69ab31234 }
b69ab31235 return numActive;
b69ab31236 }
b69ab31237}
b69ab31238
b69ab31239export const __TEST__ = {RepositoryCache, RepoMap, RefCounted};
b69ab31240
b69ab31241export const repositoryCache = new RepositoryCache();