Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions vendor/wheels/tests/specs/model/sqlServerProbeRetrySpec.cfc
Original file line number Diff line number Diff line change
@@ -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);
});

});

}

}
Loading