From 0ef60a33e185ed448ab6dd813f8b1e8d7a8d0790 Mon Sep 17 00:00:00 2001 From: Sanjibani Choudhury <18418553+sanjibani@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:27:09 +0530 Subject: [PATCH 1/2] fix(cache): await db query before cache.onMutate to close stale-repopulation race The cache invalidation in PgPreparedQuery.queryWithCache was firing concurrently with the DB write via Promise.all, contradicting the in-code comment that said the DB write should resolve first. When onMutate (a single Redis Lua script call) completed before the DB write committed, a concurrent SELECT could read the pre-mutation row and repopulate the cache via cache.put(), serving stale data until TTL expiry. Repro: write-heavy workload with cache ex > DB RTT. Change Promise.all([query(), onMutate]) to sequential await. The DB write now commits before any cache keys are cleared, closing the race. Same fix applied to mysql-core, sqlite-core, gel-core, singlestore-core which shared the identical pattern. Regression test added in pg-common-cache.ts that fails on the old ordering (query:end > onMutate) and passes on the new (query:end < onMutate). Closes #5828 --- drizzle-orm/src/gel-core/session.ts | 6 ++-- drizzle-orm/src/mysql-core/session.ts | 6 ++-- drizzle-orm/src/pg-core/session.ts | 6 ++-- drizzle-orm/src/singlestore-core/session.ts | 6 ++-- drizzle-orm/src/sqlite-core/session.ts | 6 ++-- integration-tests/tests/pg/pg-common-cache.ts | 32 +++++++++++++++++++ 6 files changed, 42 insertions(+), 20 deletions(-) diff --git a/drizzle-orm/src/gel-core/session.ts b/drizzle-orm/src/gel-core/session.ts index d7844e788d..696d1826c2 100644 --- a/drizzle-orm/src/gel-core/session.ts +++ b/drizzle-orm/src/gel-core/session.ts @@ -69,10 +69,8 @@ export abstract class GelPreparedQuery implements ) && this.queryMetadata.tables.length > 0 ) { try { - const [res] = await Promise.all([ - query(), - this.cache.onMutate({ tables: this.queryMetadata.tables }), - ]); + const res = await query(); + await this.cache.onMutate({ tables: this.queryMetadata.tables }); return res; } catch (e) { throw new DrizzleQueryError(queryString, params, e as Error); diff --git a/drizzle-orm/src/mysql-core/session.ts b/drizzle-orm/src/mysql-core/session.ts index d4ae50f9b7..d79f9b1ec7 100644 --- a/drizzle-orm/src/mysql-core/session.ts +++ b/drizzle-orm/src/mysql-core/session.ts @@ -97,10 +97,8 @@ export abstract class MySqlPreparedQuery { ) && this.queryMetadata.tables.length > 0 ) { try { - const [res] = await Promise.all([ - query(), - this.cache.onMutate({ tables: this.queryMetadata.tables }), - ]); + const res = await query(); + await this.cache.onMutate({ tables: this.queryMetadata.tables }); return res; } catch (e) { throw new DrizzleQueryError(queryString, params, e as Error); diff --git a/drizzle-orm/src/pg-core/session.ts b/drizzle-orm/src/pg-core/session.ts index 2b111fa828..3487b2b14d 100644 --- a/drizzle-orm/src/pg-core/session.ts +++ b/drizzle-orm/src/pg-core/session.ts @@ -91,10 +91,8 @@ export abstract class PgPreparedQuery implements ) && this.queryMetadata.tables.length > 0 ) { try { - const [res] = await Promise.all([ - query(), - this.cache.onMutate({ tables: this.queryMetadata.tables }), - ]); + const res = await query(); + await this.cache.onMutate({ tables: this.queryMetadata.tables }); return res; } catch (e) { throw new DrizzleQueryError(queryString, params, e as Error); diff --git a/drizzle-orm/src/singlestore-core/session.ts b/drizzle-orm/src/singlestore-core/session.ts index 04a1ac6627..d2c681bd76 100644 --- a/drizzle-orm/src/singlestore-core/session.ts +++ b/drizzle-orm/src/singlestore-core/session.ts @@ -95,10 +95,8 @@ export abstract class SingleStorePreparedQuery 0 ) { try { - const [res] = await Promise.all([ - query(), - this.cache.onMutate({ tables: this.queryMetadata.tables }), - ]); + const res = await query(); + await this.cache.onMutate({ tables: this.queryMetadata.tables }); return res; } catch (e) { throw new DrizzleQueryError(queryString, params, e as Error); diff --git a/drizzle-orm/src/sqlite-core/session.ts b/drizzle-orm/src/sqlite-core/session.ts index 3c68366a06..2ef513eb9d 100644 --- a/drizzle-orm/src/sqlite-core/session.ts +++ b/drizzle-orm/src/sqlite-core/session.ts @@ -98,10 +98,8 @@ export abstract class SQLitePreparedQuery impleme ) && this.queryMetadata.tables.length > 0 ) { try { - const [res] = await Promise.all([ - query(), - this.cache.onMutate({ tables: this.queryMetadata.tables }), - ]); + const res = await query(); + await this.cache.onMutate({ tables: this.queryMetadata.tables }); return res; } catch (e) { throw new DrizzleQueryError(queryString, params, e as Error); diff --git a/integration-tests/tests/pg/pg-common-cache.ts b/integration-tests/tests/pg/pg-common-cache.ts index 3d34e43d91..bd52af1ad4 100644 --- a/integration-tests/tests/pg/pg-common-cache.ts +++ b/integration-tests/tests/pg/pg-common-cache.ts @@ -196,6 +196,38 @@ export function tests() { expect(spyInvalidate).toHaveBeenCalledTimes(1); }); + test('write: db query resolves before onMutate (no concurrent cache race)', async (ctx) => { + const { db } = ctx.cachedPg; + + // Track invocation order to verify the DB write commits before cache invalidation. + const callOrder: string[] = []; + + // @ts-expect-error + vi.spyOn(db.$cache, 'onMutate').mockImplementation(async () => { + callOrder.push('onMutate'); + }); + + // Patch the underlying session.query to record when the DB write resolves. + const session = (db as any).session; + const originalQuery = session?.query?.bind(session); + if (originalQuery) { + vi.spyOn(session, 'query').mockImplementation(async (...args: any[]) => { + callOrder.push('query:start'); + const result = await originalQuery(...args); + callOrder.push('query:end'); + return result; + }); + } + + await db.insert(usersTable).values({ name: 'Jane' }); + + const queryEndIdx = callOrder.indexOf('query:end'); + const onMutateIdx = callOrder.indexOf('onMutate'); + expect(queryEndIdx).toBeGreaterThanOrEqual(0); + expect(onMutateIdx).toBeGreaterThanOrEqual(0); + expect(queryEndIdx).toBeLessThan(onMutateIdx); + }); + test('default global config + enable cache on select + disable invalidate: get, put', async (ctx) => { const { db } = ctx.cachedPg; From eb985b059b6e42a30e7ce5e656e85427d2efb1b2 Mon Sep 17 00:00:00 2001 From: Sanjibani Choudhury <18418553+sanjibani@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:28:53 +0530 Subject: [PATCH 2/2] fix(drizzle-kit): preserve MySQL enum values containing commas The MySQL serializer's handleEnumType helper reparses the generated enum('a,b','c') SQL string by splitting on raw commas. That collapses 'a,b' into two pieces 'a' and 'b', corrupting the snapshot type to enum('','','c') when an enum value contains a comma. Parse the value list as quoted SQL string literals instead. The new matchAll pattern matches 'value' segments and tolerates escaped '' for embedded single quotes (consistent with the existing escapeSingleQuotes round-trip). Closes #5957. --- drizzle-kit/src/serializer/mysqlSerializer.ts | 5 ++- drizzle-kit/tests/mysql.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/drizzle-kit/src/serializer/mysqlSerializer.ts b/drizzle-kit/src/serializer/mysqlSerializer.ts index 322d8957f8..f6b880bbeb 100644 --- a/drizzle-kit/src/serializer/mysqlSerializer.ts +++ b/drizzle-kit/src/serializer/mysqlSerializer.ts @@ -34,9 +34,8 @@ export const indexName = (tableName: string, columns: string[]) => { }; const handleEnumType = (type: string) => { - let str = type.split('(')[1]; - str = str.substring(0, str.length - 1); - const values = str.split(',').map((v) => `'${escapeSingleQuotes(v.substring(1, v.length - 1))}'`); + const str = type.split('(')[1].slice(0, -1); + const values = [...str.matchAll(/'((?:[^']|'')*)'/g)].map((m) => `'${escapeSingleQuotes(m[1])}'`); return `enum(${values.join(',')})`; }; diff --git a/drizzle-kit/tests/mysql.test.ts b/drizzle-kit/tests/mysql.test.ts index 23781f41d6..f535ce7a99 100644 --- a/drizzle-kit/tests/mysql.test.ts +++ b/drizzle-kit/tests/mysql.test.ts @@ -896,3 +896,35 @@ test('add table with ts enum', async () => { checkConstraints: [], }); }); + +test('add table with enum value containing comma', async () => { + const to = { + users: mysqlTable('users', { + enum: mysqlEnum('enum', ['a,b', 'c']), + }), + }; + + const { statements } = await diffTestSchemasMysql({}, to, []); + + expect(statements.length).toBe(1); + expect(statements[0]).toStrictEqual({ + type: 'create_table', + tableName: 'users', + schema: undefined, + columns: [{ + autoincrement: false, + name: 'enum', + notNull: false, + primaryKey: false, + type: "enum('a,b','c')", + }], + compositePKs: [], + internals: { + tables: {}, + indexes: {}, + }, + uniqueConstraints: [], + compositePkName: '', + checkConstraints: [], + }); +});