Skip to content

Commit c3847e6

Browse files
committed
fix(webapp): lock the variable row in every env var writer
Creating values for an existing variable and editing a single value did not lock the variable row before touching value rows, so a concurrent delete could still form a lock cycle with them. Both now lock the variable row up front, the same way edit and the delete paths do, and the lock helper's docblock is the single statement of that order. Also makes the concurrent parent-delete test deterministic by starting the bulk delete only after the competing delete has run.
1 parent 9e943ea commit c3847e6

2 files changed

Lines changed: 23 additions & 7 deletions

File tree

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,9 @@ export type EnvironmentVariableValueRow = {
8686
};
8787

8888
/**
89-
* Locks the given variable rows for the rest of the transaction. Every delete path takes this
90-
* lock before touching any value row, the same variable-then-value order `create` uses through
91-
* its variable upsert, so a concurrent import and a delete never wait on each other in a cycle.
89+
* Locks the given variable rows for the rest of the transaction. Every writer of value rows
90+
* calls this before touching any value row, so all of them take locks in the same
91+
* variable-then-value order and no two can wait on each other in a cycle.
9292
*/
9393
async function lockEnvironmentVariableRows(tx: PrismaClientOrTransaction, variableIds: string[]) {
9494
if (variableIds.length === 0) {
@@ -107,8 +107,9 @@ async function lockEnvironmentVariableRows(tx: PrismaClientOrTransaction, variab
107107
* with no values afterwards is removed as well. The affected variable rows are locked FOR UPDATE
108108
* before anything else: inserting a value takes a FOR KEY SHARE lock on its variable row, which
109109
* conflicts with FOR UPDATE, so an in-flight insert makes the lock wait and the emptiness check
110-
* then sees the new value, while an insert that starts later waits for the commit and recreates
111-
* the variable through its upsert.
110+
* then sees the new value. An insert that starts later waits for the commit and then either
111+
* recreates the variable or fails its foreign key check and fails the import for that key, in
112+
* both cases without losing data.
112113
*/
113114
export async function deleteEnvironmentVariableValueRows(
114115
tx: PrismaClientOrTransaction,
@@ -314,6 +315,8 @@ export class EnvironmentVariablesRepository implements Repository {
314315
update: {},
315316
});
316317

318+
await lockEnvironmentVariableRows(tx, [environmentVariable.id]);
319+
317320
const secretStore = getSecretStore("DATABASE", {
318321
prismaClient: tx,
319322
});
@@ -631,6 +634,8 @@ export class EnvironmentVariablesRepository implements Repository {
631634

632635
try {
633636
await $transaction(this.prismaClient, "edit env var value", async (tx) => {
637+
await lockEnvironmentVariableRows(tx, [options.id]);
638+
634639
const secretStore = getSecretStore("DATABASE", {
635640
prismaClient: tx,
636641
});

apps/webapp/test/environmentVariablesRepository.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,17 +1131,28 @@ describe("EnvironmentVariablesRepository value deletes", () => {
11311131
const deleterHoldsRow = new Promise<void>((resolve) => {
11321132
releaseDeleter = resolve;
11331133
});
1134+
let markRowDeleted: () => void = () => {};
1135+
const rowDeleted = new Promise<void>((resolve) => {
1136+
markRowDeleted = resolve;
1137+
});
11341138
const deleter = prisma.$transaction(async (tx) => {
11351139
await tx.environmentVariableValue.delete({ where: { id: parentValue.id } });
1140+
markRowDeleted();
11361141
await deleterHoldsRow;
11371142
});
1143+
await rowDeleted;
1144+
11381145
const bulk = repository.deleteValues(project.id, {
11391146
environmentId: branch.id,
11401147
keys: ["SHARED"],
11411148
onlyShadowingParent: true,
11421149
});
1143-
await new Promise((resolve) => setTimeout(resolve, 300));
1144-
releaseDeleter();
1150+
bulk.catch(() => undefined);
1151+
try {
1152+
await new Promise((resolve) => setTimeout(resolve, 300));
1153+
} finally {
1154+
releaseDeleter();
1155+
}
11451156
await deleter;
11461157

11471158
expect(await bulk).toEqual({ deleted: [], skipped: ["SHARED"] });

0 commit comments

Comments
 (0)