From a057e6881babdca1805612eb667f795634d28c05 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 02:28:27 -0700 Subject: [PATCH] fix(model): retry a failed SQL Server probe after a back-off The SQL Server adapter reads two facts once per datasource and keeps them in application.wheels: the database's compatibility level (STRING_SPLIT, #4321) and the types the driver binds dates as (#4329). A failed read was kept too, so one transient error disabled STRING_SPLIT, or date-list splitting, until the next reload. A failed read is no longer kept. Its time is recorded, the answer is "not supported" until $probeRetrySeconds() (60) have passed, and then the probe runs again; a good read is kept as before. The back-off stops a broken probe from running before every long statement. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../MicrosoftSQLServerModel.cfc | 70 +++++++++++++- .../specs/model/sqlServerProbeRetrySpec.cfc | 95 +++++++++++++++++++ 2 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 vendor/wheels/tests/specs/model/sqlServerProbeRetrySpec.cfc diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index bcc130fe88..c5e7a7507e 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -329,7 +329,8 @@ component extends="wheels.databaseAdapters.Base" output=false { /** * Internal function. True when the datasource's database has compatibility level 130 or higher, * which STRING_SPLIT needs. Read once per datasource and kept in the application's Wheels - * settings, so an application reload reads it again. + * settings, so an application reload reads it again. A failed read isn't kept: it is tried again + * once $probeRetrySeconds() have passed, and until then the answer is false. */ public boolean function $supportsStringSplit(required string dataSource) { local.appKey = $appKey(); @@ -339,7 +340,16 @@ component extends="wheels.databaseAdapters.Base" output=false { } local.levels = application[local.appKey].sqlServerCompatibilityLevels; if (!StructKeyExists(local.levels, local.dataSource)) { - local.levels[local.dataSource] = $readCompatibilityLevel(local.dataSource); + if ($probeFailedRecently(probe = "compatibilityLevel", dataSource = local.dataSource)) { + return false; + } + local.level = $readCompatibilityLevel(local.dataSource); + if (local.level <= 0) { + $noteProbeResult(probe = "compatibilityLevel", dataSource = local.dataSource, failed = true); + return false; + } + $noteProbeResult(probe = "compatibilityLevel", dataSource = local.dataSource, failed = false); + local.levels[local.dataSource] = local.level; } return local.levels[local.dataSource] >= 130; } @@ -367,7 +377,8 @@ component extends="wheels.databaseAdapters.Base" output=false { * Internal function. The CAST each date or timestamp IN list is split with, keyed by cf_sql type: * the type the datasource's driver binds that cf_sql type as, so a split list matches exactly * the rows a list bound one parameter per value matches (#4318). Read once per datasource and - * kept in the application's Wheels settings, like the compatibility level. + * kept in the application's Wheels settings, like the compatibility level; a failed read is + * tried again once $probeRetrySeconds() have passed, and until then no date list is split. */ public struct function $stringSplitDateCasts(required string dataSource) { local.appKey = $appKey(); @@ -377,11 +388,62 @@ component extends="wheels.databaseAdapters.Base" output=false { } local.bindTypes = application[local.appKey].sqlServerDateBindTypes; if (!StructKeyExists(local.bindTypes, local.dataSource)) { - local.bindTypes[local.dataSource] = $readDateBindTypes(local.dataSource); + if ($probeFailedRecently(probe = "dateBindTypes", dataSource = local.dataSource)) { + return {}; + } + local.read = $readDateBindTypes(local.dataSource); + $noteProbeResult(probe = "dateBindTypes", dataSource = local.dataSource, failed = StructIsEmpty(local.read)); + if (StructIsEmpty(local.read)) { + return {}; + } + local.bindTypes[local.dataSource] = local.read; } return $dateCastsFor(local.bindTypes[local.dataSource]); } + /** + * Internal function. True when `probe` failed for `dataSource` less than $probeRetrySeconds() ago, + * so it isn't run again yet: a broken probe would otherwise run before every long statement. + */ + public boolean function $probeFailedRecently(required string probe, required string dataSource) { + local.failures = $probeFailures(); + local.key = arguments.probe & "|" & arguments.dataSource; + return StructKeyExists(local.failures, local.key) && GetTickCount() - local.failures[local.key] < $probeRetrySeconds() * 1000; + } + + /** + * Internal function. Records when `probe` failed for `dataSource`, or forgets an earlier failure + * once it succeeds. + */ + public void function $noteProbeResult(required string probe, required string dataSource, required boolean failed) { + local.failures = $probeFailures(); + local.key = arguments.probe & "|" & arguments.dataSource; + if (arguments.failed) { + local.failures[local.key] = GetTickCount(); + } else { + StructDelete(local.failures, local.key); + } + } + + /** + * Internal function. When each SQL Server probe last failed, keyed by `probe|dataSource`, kept in + * the application's Wheels settings so a reload starts afresh. + */ + public struct function $probeFailures() { + local.appKey = $appKey(); + if (!StructKeyExists(application[local.appKey], "sqlServerProbeFailures")) { + application[local.appKey].sqlServerProbeFailures = {}; + } + return application[local.appKey].sqlServerProbeFailures; + } + + /** + * Internal function. How long a failed SQL Server probe waits before it is tried again. + */ + public numeric function $probeRetrySeconds() { + return 60; + } + /** * Internal function. Casts for the bind types $readDateBindTypes() found. Only the bind types the * split form is proven to mirror get one: a timestamp bound as datetime2(7) and a date bound as diff --git a/vendor/wheels/tests/specs/model/sqlServerProbeRetrySpec.cfc b/vendor/wheels/tests/specs/model/sqlServerProbeRetrySpec.cfc new file mode 100644 index 0000000000..371ed486b6 --- /dev/null +++ b/vendor/wheels/tests/specs/model/sqlServerProbeRetrySpec.cfc @@ -0,0 +1,95 @@ +/** + * SQL Server's per-datasource probes (compatibility level for STRING_SPLIT, #4103; the driver's date + * bind types, #4318) keep a successful read for the application's lifetime. A failed read isn't + * kept: it is tried again once $probeRetrySeconds() have passed, and not before, so a broken probe + * doesn't run before every long statement. The reads are stubbed, so this runs on every database. + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.dataSource = "wheels_probe_retry_" & Replace(CreateUUID(), "-", "", "all"); + } + + function afterAll() { + forgetProbes(); + } + + function forgetProbes() { + for (var store in ["sqlServerCompatibilityLevels", "sqlServerDateBindTypes"]) { + if (StructKeyExists(application.wheels, store)) { + StructDelete(application.wheels[store], variables.dataSource); + } + } + if (StructKeyExists(application.wheels, "sqlServerProbeFailures")) { + StructDelete(application.wheels.sqlServerProbeFailures, "compatibilityLevel|" & variables.dataSource); + StructDelete(application.wheels.sqlServerProbeFailures, "dateBindTypes|" & variables.dataSource); + } + } + + // A SQL Server adapter for a datasource no other spec uses, with its probe reads stubbed. + function stubbedAdapter() { + var adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel").$init( + dataSource = variables.dataSource, + username = "", + password = "" + ); + prepareMock(adapter); + return adapter; + } + + // Moves a recorded failure `seconds` into the past. + function ageFailure(required string probe, required numeric seconds) { + var key = arguments.probe & "|" & variables.dataSource; + application.wheels.sqlServerProbeFailures[key] = application.wheels.sqlServerProbeFailures[key] - arguments.seconds * 1000; + } + + function run() { + + describe("SQL Server probes after a failed read", () => { + + beforeEach(() => { + forgetProbes(); + }); + + it("retries the compatibility level only after the back-off, then keeps a good read", () => { + var adapter = stubbedAdapter(); + adapter.$("$readCompatibilityLevel").$results(0, 150); + expect(adapter.$supportsStringSplit(variables.dataSource)).toBeFalse(); + expect(adapter.$supportsStringSplit(variables.dataSource)).toBeFalse(); + expect(adapter.$count("$readCompatibilityLevel")).toBe(1, "read again within the back-off"); + expect(StructKeyExists(application.wheels.sqlServerCompatibilityLevels, variables.dataSource)).toBeFalse("a failed read was kept"); + ageFailure("compatibilityLevel", adapter.$probeRetrySeconds() + 1); + expect(adapter.$supportsStringSplit(variables.dataSource)).toBeTrue(); + expect(adapter.$supportsStringSplit(variables.dataSource)).toBeTrue(); + expect(adapter.$count("$readCompatibilityLevel")).toBe(2, "a good read is kept"); + expect(application.wheels.sqlServerCompatibilityLevels[variables.dataSource]).toBe(150); + expect(StructKeyExists(application.wheels.sqlServerProbeFailures, "compatibilityLevel|" & variables.dataSource)).toBeFalse(); + }); + + it("retries the date bind types only after the back-off, then keeps a good read", () => { + var adapter = stubbedAdapter(); + adapter.$("$readDateBindTypes").$results({}, {cf_sql_timestamp = "datetime2/7", cf_sql_date = "date/0"}); + expect(StructIsEmpty(adapter.$stringSplitDateCasts(variables.dataSource))).toBeTrue(); + expect(StructIsEmpty(adapter.$stringSplitDateCasts(variables.dataSource))).toBeTrue(); + expect(adapter.$count("$readDateBindTypes")).toBe(1, "read again within the back-off"); + expect(StructKeyExists(application.wheels.sqlServerDateBindTypes, variables.dataSource)).toBeFalse("a failed read was kept"); + ageFailure("dateBindTypes", adapter.$probeRetrySeconds() + 1); + expect(adapter.$stringSplitDateCasts(variables.dataSource).cf_sql_timestamp).toBe("DATETIME2(7)"); + expect(adapter.$stringSplitDateCasts(variables.dataSource).cf_sql_date).toBe("DATE"); + expect(adapter.$count("$readDateBindTypes")).toBe(2, "a good read is kept"); + }); + + it("keeps a level below 130 as a good read, not a failure", () => { + var adapter = stubbedAdapter(); + adapter.$("$readCompatibilityLevel").$results(120); + expect(adapter.$supportsStringSplit(variables.dataSource)).toBeFalse(); + expect(adapter.$supportsStringSplit(variables.dataSource)).toBeFalse(); + expect(adapter.$count("$readCompatibilityLevel")).toBe(1); + expect(application.wheels.sqlServerCompatibilityLevels[variables.dataSource]).toBe(120); + }); + + }); + + } + +}