Skip to content

Commit 89154be

Browse files
committed
feat: implement optimistic prefetching for next edit suggestions
- Add prefetch chain mechanism that triggers when edits are shown - Prefetch up to 3 edits deep to reduce latency for sequential edits - Integrate with existing NextEditCache infrastructure - Track active prefetches to avoid duplicates - Clean up resources on disposal
1 parent 2e51162 commit 89154be

File tree

2 files changed

+166
-9
lines changed

2 files changed

+166
-9
lines changed

src/extension/inlineEdits/node/nextEditProvider.ts

Lines changed: 165 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type * as vscode from 'vscode';
77
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
8+
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
89
import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';
910
import { RootedEdit } from '../../../platform/inlineEdits/common/dataTypes/edit';
1011
import { RootedLineEdit } from '../../../platform/inlineEdits/common/dataTypes/rootedLineEdit';
@@ -20,9 +21,9 @@ import { IExperimentationService } from '../../../platform/telemetry/common/null
2021
import { Result } from '../../../util/common/result';
2122
import { createTracer, ITracer } from '../../../util/common/tracing';
2223
import { assert } from '../../../util/vs/base/common/assert';
23-
import { DeferredPromise, timeout, TimeoutTimer } from '../../../util/vs/base/common/async';
24+
import { DeferredPromise, TimeoutTimer } from '../../../util/vs/base/common/async';
2425
import { CachedFunction } from '../../../util/vs/base/common/cache';
25-
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
26+
import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
2627
import { BugIndicatingError } from '../../../util/vs/base/common/errors';
2728
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../util/vs/base/common/lifecycle';
2829
import { LRUCache } from '../../../util/vs/base/common/map';
@@ -38,7 +39,7 @@ import { RejectionCollector } from '../common/rejectionCollector';
3839
import { DebugRecorder } from './debugRecorder';
3940
import { INesConfigs } from './nesConfigs';
4041
import { CachedOrRebasedEdit, NextEditCache } from './nextEditCache';
41-
import { LlmNESTelemetryBuilder } from './nextEditProviderTelemetry';
42+
import { LlmNESTelemetryBuilder, NextEditProviderTelemetryBuilder } from './nextEditProviderTelemetry';
4243
import { INextEditResult, NextEditResult } from './nextEditResult';
4344

4445
export interface INextEditProvider<T extends INextEditResult, TTelemetry, TData = void> extends IDisposable {
@@ -66,6 +67,10 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
6667
private readonly _nextEditCache: NextEditCache;
6768
private readonly _recentlyShownCache = new RecentlyShownCache();
6869

70+
// Track active prefetch requests to avoid duplicates
71+
private readonly _activePrefetches = new Map<string, CancellationTokenSource>();
72+
private readonly _prefetchChainDepth = 3; // Maximum depth for prediction chain
73+
6974
private _pendingStatelessNextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit> | null = null;
7075

7176
private _lastShownTime = 0;
@@ -92,6 +97,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
9297
@ISnippyService private readonly _snippyService: ISnippyService,
9398
@ILogService private readonly _logService: ILogService,
9499
@IExperimentationService private readonly _expService: IExperimentationService,
100+
@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,
95101
) {
96102
super();
97103

@@ -117,6 +123,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
117123
}
118124

119125
public async getNextEdit(docId: DocumentId, context: vscode.InlineCompletionContext, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken, telemetryBuilder: LlmNESTelemetryBuilder): Promise<NextEditResult> {
126+
const startTime = Date.now();
120127
const tracer = this._tracer.sub('getNextEdit');
121128

122129
this._lastTriggerTime = Date.now();
@@ -141,6 +148,9 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
141148
telemetryBuilder.setNESConfigs({ ...nesConfigs });
142149
logContext.addCodeblockToLog(JSON.stringify(nesConfigs, null, '\t'));
143150

151+
// The existing cache lookup will now find our prefetched results!
152+
153+
144154
const recentlyShownCachedEdit = this._recentlyShownCache.get(docId, documentAtInvocationTime);
145155
const cachedEdit = this._nextEditCache.lookupNextEdit(docId, documentAtInvocationTime, doc.selection.get(), nesConfigs);
146156

@@ -158,8 +168,6 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
158168
let req: NextEditFetchRequest;
159169
let targetDocumentId = docId;
160170

161-
const cacheDelay = this._configService.getExperimentBasedConfig(ConfigKey.Internal.InlineEditsCacheDelay, this._expService);
162-
163171
if (recentlyShownCachedEdit) {
164172
tracer.trace('using recently shown cached edit');
165173
edit = recentlyShownCachedEdit[0];
@@ -173,10 +181,12 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
173181
// back-date the recording bookmark of the cached edit to the bookmark of the original request.
174182
logContext.recordingBookmark = req.log.recordingBookmark;
175183

176-
await timeout(cacheDelay);
184+
// No artificial delay for cache hits
177185

178186
} else if (cachedEdit) {
187+
const timeElapsed = Date.now() - startTime;
179188
tracer.trace('using cached edit');
189+
this._logService.logger.info(`[NextEditProvider] CACHE HIT (prefetched) - ${timeElapsed}ms, subsequentN=${cachedEdit.subsequentN}`);
180190
edit = cachedEdit.rebasedEdit || cachedEdit.edit;
181191
req = cachedEdit.source;
182192
logContext.setIsCachedResult(cachedEdit.source.log);
@@ -187,7 +197,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
187197
// back-date the recording bookmark of the cached edit to the bookmark of the original request.
188198
logContext.recordingBookmark = req.log.recordingBookmark;
189199

190-
await timeout(cacheDelay);
200+
// No artificial delay for cache hits
191201

192202
} else {
193203
tracer.trace('fetching next edit');
@@ -305,6 +315,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
305315
private async fetchNextEdit(req: NextEditFetchRequest, doc: IObservableDocument, nesConfigs: INesConfigs, telemetryBuilder: LlmNESTelemetryBuilder, cancellationToken: CancellationToken): Promise<Result<CachedOrRebasedEdit, NoNextEditReason>> {
306316
const curDocId = doc.id;
307317
const tracer = this._tracer.sub('fetchNextEdit');
318+
308319
const historyContext = this._historyContextProvider.getHistoryContext(curDocId);
309320

310321
if (!historyContext) {
@@ -630,11 +641,144 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
630641

631642
public handleShown(suggestion: NextEditResult) {
632643
this._lastShownTime = Date.now();
644+
645+
// Trigger optimistic prefetching as soon as the edit is shown
646+
// This assumes the user will accept it and pre-fetches the next edits
647+
if (suggestion.result?.edit) {
648+
const docId = suggestion.result.targetDocumentId ||
649+
DocumentId.create(suggestion.source.log.filePath);
650+
const doc = this._workspace.getDocument(docId);
651+
if (!doc) {
652+
return;
653+
}
654+
655+
// Calculate what the document state would be AFTER accepting this edit
656+
const baseDocState = suggestion.result.documentBeforeEdits;
657+
const stringEdit = StringEdit.create([suggestion.result.edit]);
658+
const futureDocState = stringEdit.applyOnText(baseDocState);
659+
660+
// Start the prefetch chain
661+
this._startPrefetchChain(docId, futureDocState, 0);
662+
}
663+
}
664+
665+
private async _startPrefetchChain(docId: DocumentId, documentState: StringText, depth: number): Promise<void> {
666+
if (depth >= this._prefetchChainDepth) {
667+
return;
668+
}
669+
670+
// Create a unique key for this prefetch based on document state
671+
const prefetchKey = `${docId}:${documentState.value.length}:${depth}`;
672+
673+
// Cancel any existing prefetch for this state
674+
const existingPrefetch = this._activePrefetches.get(prefetchKey);
675+
if (existingPrefetch) {
676+
existingPrefetch.cancel();
677+
this._activePrefetches.delete(prefetchKey);
678+
}
679+
680+
const cancellationSource = new CancellationTokenSource();
681+
this._activePrefetches.set(prefetchKey, cancellationSource);
682+
683+
try {
684+
685+
// Create a synthetic document that represents the future state
686+
const syntheticDoc = this._workspace.getDocument(docId);
687+
if (!syntheticDoc || cancellationSource.token.isCancellationRequested) {
688+
return;
689+
}
690+
691+
// Perform the prefetch - this will populate the cache
692+
const result = await this._performPrefetch(docId, documentState, cancellationSource.token);
693+
694+
if (result && result.result?.edit && !cancellationSource.token.isCancellationRequested) {
695+
// Apply the edit to get the next document state
696+
const nextEdit = StringEdit.create([result.result.edit]);
697+
const nextDocumentState = nextEdit.applyOnText(documentState);
698+
699+
// Check if the document actually changed
700+
if (nextDocumentState.value === documentState.value) {
701+
// Edit made no changes, no point in continuing the chain
702+
return;
703+
}
704+
705+
// Continue the chain only if the document changed
706+
await this._startPrefetchChain(docId, nextDocumentState, depth + 1);
707+
}
708+
} catch (err) {
709+
this._logService.logger.trace(`[Prefetch] Error at depth ${depth}: ${err}`);
710+
} finally {
711+
this._activePrefetches.delete(prefetchKey);
712+
}
713+
}
714+
715+
private async _performPrefetch(docId: DocumentId, futureDocumentState: StringText, cancellationToken: CancellationToken): Promise<NextEditResult | undefined> {
716+
const doc = this._workspace.getDocument(docId);
717+
if (!doc) {
718+
return undefined;
719+
}
720+
721+
// Create synthetic context and telemetry for the prefetch
722+
const syntheticContext: vscode.InlineCompletionContext = {
723+
triggerKind: 0 as vscode.InlineCompletionTriggerKind,
724+
selectedCompletionInfo: undefined,
725+
requestUuid: generateUuid()
726+
};
727+
728+
const logContext = new InlineEditRequestLogContext(docId.toUri().toString(), 0, syntheticContext);
729+
const req = new NextEditFetchRequest(logContext, Date.now());
730+
731+
// Create a proper telemetry builder for prefetch
732+
const telemetryBuilder = new NextEditProviderTelemetryBuilder(this._gitExtensionService, this.ID, doc);
733+
telemetryBuilder.nesBuilder.setHeaderRequestId(req.headerRequestId);
734+
telemetryBuilder.nesBuilder.setIsFromCache(); // Mark as prefetched
735+
736+
const nesConfigs: INesConfigs = {
737+
isAsyncCompletions: this._configService.getExperimentBasedConfig(ConfigKey.Internal.InlineEditsAsyncCompletions, this._expService),
738+
isRevisedCacheStrategy: this._configService.getExperimentBasedConfig(ConfigKey.Internal.InlineEditsRevisedCacheStrategy, this._expService),
739+
isCacheTracksRejections: this._configService.getExperimentBasedConfig(ConfigKey.Internal.InlineEditsCacheTracksRejections, this._expService),
740+
isRecentlyShownCacheEnabled: this._configService.getExperimentBasedConfig(ConfigKey.Internal.InlineEditsRecentlyShownCacheEnabled, this._expService),
741+
};
742+
743+
// Create a synthetic document wrapper that returns our future state
744+
const syntheticDocWrapper = {
745+
...doc,
746+
value: {
747+
get: () => futureDocumentState
748+
}
749+
};
750+
751+
try {
752+
// This will fetch the edit and populate the cache via pushEdit
753+
const result = await this.fetchNextEdit(req, syntheticDocWrapper as IObservableDocument, nesConfigs, telemetryBuilder.nesBuilder, cancellationToken);
754+
755+
if (result.isOk() && result.val.edit) {
756+
const nextEditResult = new NextEditResult(
757+
logContext.requestId,
758+
req,
759+
{
760+
edit: result.val.edit,
761+
documentBeforeEdits: futureDocumentState,
762+
showRangePreference: this._statelessNextEditProvider.showNextEditPreference ?? ShowNextEditPreference.AroundEdit,
763+
targetDocumentId: docId
764+
}
765+
);
766+
return nextEditResult;
767+
}
768+
} catch (err) {
769+
this._logService.logger.trace(`[Prefetch] Error during fetch: ${err}`);
770+
} finally {
771+
telemetryBuilder.dispose();
772+
}
773+
774+
return undefined;
633775
}
634776

635777
public handleAcceptance(docId: DocumentId, suggestion: NextEditResult) {
636778
this.runSnippy(docId, suggestion);
637779
this._statelessNextEditProvider.handleAcceptance?.();
780+
781+
// Optimistic fetching already triggered in handleShown
638782
}
639783

640784
public handleRejection(docId: DocumentId, suggestion: NextEditResult) {
@@ -656,7 +800,19 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
656800
this._statelessNextEditProvider.handleRejection?.();
657801
}
658802

659-
public handleIgnored(docId: DocumentId, suggestion: NextEditResult, supersededBy: INextEditResult | undefined): void { }
803+
public handleIgnored(_docId: DocumentId, _suggestion: NextEditResult, _supersededBy: INextEditResult | undefined): void {
804+
// No-op for now
805+
}
806+
807+
public override dispose(): void {
808+
// Cancel all active prefetches
809+
for (const [, cancellationSource] of this._activePrefetches) {
810+
cancellationSource.cancel();
811+
}
812+
this._activePrefetches.clear();
813+
814+
super.dispose();
815+
}
660816

661817
private async runSnippy(docId: DocumentId, suggestion: NextEditResult) {
662818
if (suggestion.result === undefined) {
@@ -718,3 +874,4 @@ class RecentlyShownCache {
718874
return docId.uri + ';' + documentContent.value;
719875
}
720876
}
877+

src/extension/inlineEdits/test/node/nextEditProviderCaching.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe('NextEditProvider Caching', () => {
8181
}
8282
};
8383

84-
const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace), undefined, configService, snippyService, logService, expService);
84+
const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace), undefined, configService, snippyService, logService, expService, gitExtensionService);
8585

8686
const doc = obsWorkspace.addDocument({
8787
id: DocumentId.create(URI.file('/test/test.ts').toString()),

0 commit comments

Comments
 (0)