Skip to content

Commit 56cd1a6

Browse files
geroplona-agent
andauthored
[server] Retry rev-mismatched code-sync inserts server-side (#21319)
When multiple workspaces sync globalState concurrently, the If-Match rev becomes stale between the client's GET and POST. The VS Code client retries 412 responses with no recursion limit, exhausting its 100-request/5-min budget and triggering 'Settings sync is suspended'. Retry the insert up to 3 times server-side with the current latest rev, absorbing transient concurrency conflicts before they reach the client. Co-authored-by: Ona <no-reply@ona.com>
1 parent b0967db commit 56cd1a6

File tree

3 files changed

+96
-39
lines changed

3 files changed

+96
-39
lines changed

components/gitpod-db/src/typeorm/code-sync-resource-db.spec.db.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,30 @@ export class CodeSyncResourceDBSpec {
6161
expect(inserted).to.be.true;
6262
}
6363

64+
@test()
65+
async getLatestRevision(): Promise<void> {
66+
const kind = "machines";
67+
68+
// Returns "0" when no resources exist
69+
let latestRev = await this.db.getLatestRevision(this.userId, kind, undefined);
70+
expect(latestRev).to.equal("0");
71+
72+
// Returns the rev of the most recent insert
73+
const rev1 = await this.db.insert(this.userId, kind, undefined, undefined, async () => {});
74+
latestRev = await this.db.getLatestRevision(this.userId, kind, undefined);
75+
expect(latestRev).to.equal(rev1);
76+
77+
// Returns the newest rev after a second insert
78+
const rev2 = await this.db.insert(this.userId, kind, undefined, undefined, async () => {});
79+
latestRev = await this.db.getLatestRevision(this.userId, kind, undefined);
80+
expect(latestRev).to.equal(rev2);
81+
expect(latestRev).not.to.equal(rev1);
82+
83+
// Can be used to successfully insert with the correct rev
84+
const rev3 = await this.db.insert(this.userId, kind, undefined, latestRev, async () => {});
85+
expect(rev3).not.to.be.undefined;
86+
}
87+
6488
@test()
6589
async getDeleteResources(): Promise<void> {
6690
const kind = "machines";

components/gitpod-db/src/typeorm/code-sync-resource-db.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ export class CodeSyncResourceDB {
127127
return this.doGetResources(connection.manager, userId, kind, collection);
128128
}
129129

130+
/**
131+
* Returns the rev of the most recent resource entry, or "0" if none exists.
132+
* Used by the code-sync service to resolve the current rev for server-side
133+
* retry of rev-mismatched inserts.
134+
*/
135+
async getLatestRevision(userId: string, kind: ServerResource, collection: string | undefined): Promise<string> {
136+
const connection = await this.typeORM.getConnection();
137+
const latest = await connection.manager
138+
.createQueryBuilder(DBCodeSyncResource, "resource")
139+
.where("resource.userId = :userId AND resource.kind = :kind AND resource.collection = :collection", {
140+
userId,
141+
kind,
142+
collection: collection || uuid.NIL,
143+
})
144+
.orderBy("resource.created", "DESC")
145+
.getOne();
146+
return latest?.rev ?? "0";
147+
}
148+
130149
async deleteSettingsSyncResources(userId: string, doDelete: () => Promise<void>): Promise<void> {
131150
const connection = await this.typeORM.getConnection();
132151
await connection.transaction(async (manager) => {

components/server/src/code-sync/code-sync-service.ts

Lines changed: 53 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import { SubjectId } from "../auth/subject-id";
3535
const defaultRevLimit = 20;
3636
// It should keep it aligned with client_max_body_size for /code-sync location.
3737
const defaultContentLimit = "1Mb";
38+
// Max server-side retries when a rev mismatch (412) occurs during insert.
39+
// Absorbs concurrency conflicts from multiple workspaces writing the same resource,
40+
// preventing the VS Code client from entering an unbounded retry loop that exhausts
41+
// its 100-request/5-min budget and triggers "Settings sync is suspended" banners.
42+
const maxInsertRetries = 3;
3843
export type CodeSyncConfig = Partial<{
3944
revLimit: number;
4045
contentLimit: number;
@@ -330,46 +335,55 @@ export class CodeSyncService {
330335
const isEditSessionsResource = resourceKey === "editSessions";
331336
const userId = req.user!.id;
332337
const contentType = req.headers["content-type"] || "*/*";
333-
const newRev = await this.db.insert(
334-
userId,
335-
resourceKey,
336-
collection,
337-
latestRev,
338-
async (rev, oldRevs) => {
339-
const request = new UploadUrlRequest();
340-
request.setOwnerId(userId);
341-
request.setName(toObjectName(resourceKey, rev, collection));
342-
request.setContentType(contentType);
343-
const blobsClient = this.blobsProvider.getDefault();
344-
const urlResponse = await util.promisify<UploadUrlRequest, UploadUrlResponse>(
345-
blobsClient.uploadUrl.bind(blobsClient),
346-
)(request);
347-
const url = urlResponse.getUrl();
348-
const content = req.body as string;
349-
const response = await fetch(url, {
350-
timeout: 10000,
351-
method: "PUT",
352-
body: content,
353-
headers: {
354-
"content-length": req.headers["content-length"] || String(content.length),
355-
"content-type": contentType,
356-
},
357-
});
358-
if (response.status !== 200) {
359-
throw new Error(
360-
`code sync: blob service: upload failed with ${response.status} ${response.statusText}`,
361-
);
362-
}
363338

364-
if (oldRevs.length) {
365-
// Asynchonously delete old revs from storage
366-
Promise.allSettled(
367-
oldRevs.map((rev) => this.doDeleteResource(userId, resourceKey, rev, collection)),
368-
).catch(() => {});
369-
}
370-
},
371-
{ revLimit, overwrite: !isEditSessionsResource },
372-
);
339+
const doInsert = async (rev: string, oldRevs: string[]) => {
340+
const request = new UploadUrlRequest();
341+
request.setOwnerId(userId);
342+
request.setName(toObjectName(resourceKey, rev, collection));
343+
request.setContentType(contentType);
344+
const blobsClient = this.blobsProvider.getDefault();
345+
const urlResponse = await util.promisify<UploadUrlRequest, UploadUrlResponse>(
346+
blobsClient.uploadUrl.bind(blobsClient),
347+
)(request);
348+
const url = urlResponse.getUrl();
349+
const content = req.body as string;
350+
const response = await fetch(url, {
351+
timeout: 10000,
352+
method: "PUT",
353+
body: content,
354+
headers: {
355+
"content-length": req.headers["content-length"] || String(content.length),
356+
"content-type": contentType,
357+
},
358+
});
359+
if (response.status !== 200) {
360+
throw new Error(
361+
`code sync: blob service: upload failed with ${response.status} ${response.statusText}`,
362+
);
363+
}
364+
365+
if (oldRevs.length) {
366+
// Asynchronously delete old revs from storage
367+
Promise.allSettled(
368+
oldRevs.map((rev) => this.doDeleteResource(userId, resourceKey, rev, collection)),
369+
).catch(() => {});
370+
}
371+
};
372+
373+
const insertOptions = { revLimit, overwrite: !isEditSessionsResource };
374+
375+
// Try the insert with the client-provided rev first.
376+
// On rev mismatch, retry with the server's current latest rev. This absorbs
377+
// concurrency conflicts (e.g. multiple workspaces syncing globalState) server-side,
378+
// preventing the VS Code client from entering an unbounded retry loop that
379+
// exhausts its request budget and triggers "Settings sync is suspended" banners.
380+
let newRev = await this.db.insert(userId, resourceKey, collection, latestRev, doInsert, insertOptions);
381+
if (!newRev && !isEditSessionsResource && latestRev) {
382+
for (let attempt = 0; attempt < maxInsertRetries && !newRev; attempt++) {
383+
const currentLatest = await this.db.getLatestRevision(userId, resourceKey, collection);
384+
newRev = await this.db.insert(userId, resourceKey, collection, currentLatest, doInsert, insertOptions);
385+
}
386+
}
373387

374388
if (!newRev) {
375389
res.sendStatus(isEditSessionsResource ? 400 : 412);

0 commit comments

Comments
 (0)