@@ -227,10 +227,10 @@ import {
227227 SCALAR_COMPARAND_OPERATORS, SEQUENCES_TABLE, SUPPORTED_FILTER_OPERATORS_SENTENCE, TEXT_OPERATORS,
228228 TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_SUBTREE,
229229 canonicalTimeOfDay, canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, fieldReferenceOf,
230- isBindableComparand, isDeclaredFieldType, isEmptyFieldConstraint, isFilterNode, isMultiValuedColumn ,
231- isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, nonBooleanFlagWithheldMessage ,
232- nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, resolveSqliteAbsentFileTarget ,
233- safeShapePreview, withheldFilterDiagnosticOf,
230+ isBindableComparand, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isFilterNode ,
231+ isMultiValuedColumn, isNowDefaultValue, isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral,
232+ nonBooleanFlagWithheldMessage, nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType,
233+ resolveSqliteAbsentFileTarget, safeShapePreview, withheldFilterDiagnosticOf,
234234} from './sql-driver-top-level.js';
235235import type {
236236 AutoNumberReservation, ConflictTargetVerdict, FilterVerdict, IntrospectedColumn, IntrospectedForeignKey,
@@ -269,7 +269,7 @@ import { analyzeQuery } from './sql-driver-query-plan.js';
269269// [#29520 codemod] Schema Sync (syncSchema / init): the methods of the same names below hand the bodies what they read (`SchemaSyncSectionHost`).
270270import { reclaimSpace } from './sql-driver-schema-sync-section.js';
271271// [#29520 codemod] Data-lifecycle rotation (ADR-0057 P2): the methods of the same names below hand the bodies what they read (`RotationHost`).
272- import { computeTenantField, migrateMysqlDatetimeColumns, migrateMysqlTimeColumns, registerManagedObjectMetadata } from './sql-driver-rotation.js';
272+ import { computeTenantField, registerManagedObjectMetadata } from './sql-driver-rotation.js';
273273// [#29520 codemod] Managed-schema drift & reconcile (#2186): the methods of the same names below hand the bodies what they read (`ManagedDriftHost`).
274274import { buildRebuiltColumn, dropIndexIfExists, isProductionEnv, probeNullSafeUniqueDuplicates, syncTableIndexes } from './sql-driver-managed-drift.js';
275275// [#29520 codemod] Internal helpers: the methods of the same names below hand the bodies what they read (`InternalHelpersHost`).
@@ -8068,22 +8068,61 @@ export class SqlDriver implements IDataDriver {
80688068 fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any)));
80698069 }
80708070
8071- protected migrateMysqlDatetimeColumns(
8071+ protected async migrateMysqlDatetimeColumns(
80728072 table: string,
80738073 fields: Record<string, any>,
80748074 ): Promise<void> {
8075- // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`).
8076- const readIsMysql = () => this.isMysql;
8077- const readLogger = () => this.logger;
8078- const host: ThisParameterType<typeof migrateMysqlDatetimeColumns> = {
8079- get isMysql() { return readIsMysql(); },
8080- legacyMysqlTimestampColumns: (...a) => this.legacyMysqlTimestampColumns(...a),
8081- logDurabilityFailure: (...a) => this.logDurabilityFailure(...a),
8082- get logger() { return readLogger(); },
8083- nowColumnDefault: (...a) => this.nowColumnDefault(...a),
8084- runWideningAlters: (...a) => this.runWideningAlters(...a),
8085- };
8086- return migrateMysqlDatetimeColumns.call(host, table, fields);
8075+ if (!this.isMysql) return;
8076+ try {
8077+ const legacy = await this.legacyMysqlTimestampColumns(table, fields);
8078+ if (legacy.length === 0) return;
8079+
8080+ // #9354: built as a list, then run as one unit, so the whole widening of a
8081+ // table shares the ONE pinned session the bounded lock wait needs.
8082+ const statements = legacy.map((col) => {
8083+ // The default is re-stated because MySQL drops a column's DEFAULT when
8084+ // MODIFY does not repeat it, and an audit column without
8085+ // `CURRENT_TIMESTAMP(3)` would start inserting NULL.
8086+ //
8087+ // A declared `defaultValue: 'NOW()'` column is the same case (#21241),
8088+ // and the TIME twin ({@link migrateMysqlTimeColumns}) already restates
8089+ // it. Measured on MySQL 8.0.46 before this line: a legacy
8090+ // `timestamp null default current_timestamp` NOW() column came out of
8091+ // the widening as `datetime(3)` with NO default, and an insert omitting
8092+ // it answered `null`. Both take the expression a fresh column gets,
8093+ // from {@link nowColumnDefault}, so the widening cannot spell a third.
8094+ const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name);
8095+ const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue);
8096+ const nullClause = col.nullable ? 'null' : 'not null';
8097+ const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : '';
8098+ return {
8099+ sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`,
8100+ bindings: [table, col.name] as unknown[],
8101+ };
8102+ });
8103+ await this.runWideningAlters(table, statements);
8104+ this.logger.info?.(
8105+ `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` +
8106+ `keeps no milliseconds and converts through the session timezone`,
8107+ { columns: legacy.map((c) => c.name) },
8108+ );
8109+ } catch (err) {
8110+ // #9354: the ONE failure that must not be swallowed. Everything else on
8111+ // this path keeps the policy the doc comment above describes; a metadata
8112+ // lock wait that the flush bounded is the operator's answer to a command
8113+ // they ran, and dropping it would report success for work not done.
8114+ if (isDeferredDdlLockWaitRefusal(err)) throw err;
8115+ this.logDurabilityFailure(
8116+ `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` +
8117+ `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` +
8118+ `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` +
8119+ `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` +
8120+ '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' +
8121+ 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' +
8122+ 're-detected from `information_schema` and is idempotent, so re-running is safe.',
8123+ { error: err instanceof Error ? err.message : String(err) },
8124+ );
8125+ }
80878126 }
80888127
80898128 /**
@@ -8132,21 +8171,53 @@ export class SqlDriver implements IDataDriver {
81328171 * as un-widened, so the level IS the signal. Same reasoning, same level, on
81338172 * {@link migrateMysqlDatetimeColumns}.
81348173 */
8135- protected migrateMysqlTimeColumns(
8174+ protected async migrateMysqlTimeColumns(
81368175 table: string,
81378176 fields: Record<string, any>,
81388177 ): Promise<void> {
8139- // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`).
8140- const readIsMysql = () => this.isMysql;
8141- const readLogger = () => this.logger;
8142- const host: ThisParameterType<typeof migrateMysqlTimeColumns> = {
8143- get isMysql() { return readIsMysql(); },
8144- legacyMysqlTimeColumns: (...a) => this.legacyMysqlTimeColumns(...a),
8145- logDurabilityFailure: (...a) => this.logDurabilityFailure(...a),
8146- get logger() { return readLogger(); },
8147- runWideningAlters: (...a) => this.runWideningAlters(...a),
8148- };
8149- return migrateMysqlTimeColumns.call(host, table, fields);
8178+ if (!this.isMysql) return;
8179+ try {
8180+ const legacy = await this.legacyMysqlTimeColumns(table, fields);
8181+ if (legacy.length === 0) return;
8182+
8183+ // #9354: the datetime twin's seam, for the same reason — this ALTER takes
8184+ // the same exclusive metadata lock and blocks on the same holder.
8185+ const statements = legacy.map((col) => {
8186+ // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'`
8187+ // column gets the canonical UTC expression default (`nowColumnDefault`);
8188+ // its legacy `current_timestamp()` default read the SESSION's zone, so
8189+ // dropping-and-replacing it is a fix, not collateral.
8190+ const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue);
8191+ const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : '';
8192+ return {
8193+ sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`,
8194+ bindings: [table, col.name] as unknown[],
8195+ };
8196+ });
8197+ await this.runWideningAlters(table, statements);
8198+ this.logger.info?.(
8199+ `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` +
8200+ `canonical milliseconds to the nearest second, changing the stored wall clock`,
8201+ { columns: legacy.map((c) => c.name) },
8202+ );
8203+ } catch (err) {
8204+ // #9354: the ONE failure that must not be swallowed. Everything else on
8205+ // this path keeps the policy the doc comment above describes; a metadata
8206+ // lock wait that the flush bounded is the operator's answer to a command
8207+ // they ran, and dropping it would report success for work not done.
8208+ if (isDeferredDdlLockWaitRefusal(err)) throw err;
8209+ this.logDurabilityFailure(
8210+ `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` +
8211+ `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` +
8212+ `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` +
8213+ `while reads return exactly what was stored and nothing else reports the table as un-widened. ` +
8214+ 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' +
8215+ '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' +
8216+ 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' +
8217+ 'and is idempotent, so re-running is safe.',
8218+ { error: err instanceof Error ? err.message : String(err) },
8219+ );
8220+ }
81508221 }
81518222
81528223 // ── Deferred schema DDL (#3917) ────────────────────────────────────────────
0 commit comments