Follow-up scope deliberately deferred from the database-backed rate-limit enforcement fix (internal framework review 2026-06-09, finding T8). The fix made storage="database" actually enforce on every engine; these items close the remaining (bounded, disclosed) gaps.
1. Dialect-aware atomic upsert for the fixedWindow first-insert race
$dbIncrement() (vendor/wheels/middleware/RateLimiter.cfc) uses UPDATE-first + SELECT MAX + insert-on-miss + one re-read. Concurrent first requests for the same key/window can each insert a row, producing a bounded one-time-per-window undercount (at most N-1 extra requests for N racing nodes, once per window). A real fix is a per-dialect atomic upsert:
- MySQL:
INSERT ... ON DUPLICATE KEY UPDATE counter = counter + 1
- PostgreSQL / SQLite:
INSERT ... ON CONFLICT (store_key) DO UPDATE SET counter = wheels_rate_limits.counter + 1
- SQL Server:
MERGE (with HOLDLOCK) or the UPDATE-then-INSERT retry-on-2627 idiom
- Oracle:
MERGE INTO
All of these require a UNIQUE index on store_key, which item 2 currently blocks.
2. Schema redesign with a row-type discriminator
A UNIQUE(store_key) index cannot be added today because the slidingWindow strategy inserts one row per request into the same wheels_rate_limits table (counter rows and event rows share the schema). Proposed: add a row-type discriminator column (e.g. row_type in counter|event|bucket) and scope uniqueness to counter/bucket rows (partial/filtered unique index where supported, or split tables). This is a schema migration for existing wheels_rate_limits tables, so it needs an upgrade path (the table is auto-created, so probe-and-alter or rename-and-recreate are both viable).
3. Locking for DB tokenBucket / slidingWindow read-modify-write
$dbTokenBucket() and $dbSlidingWindow() do unguarded read-modify-write sequences; the in-process cflock only serializes within one node, so multi-node deployments can interleave (token over-grant / event-count skew). Options: SELECT ... FOR UPDATE where supported, optimistic concurrency via a version column, or single-statement atomic forms (UPDATE ... SET tokens = LEAST(...)).
Neither residual race is a sustainable attacker bypass — the undercount is bounded and once-per-window — but they should be closed for strict multi-node accounting.
4. Document engine support for storage="database"
$detectDatabaseType() maps MySQL, PostgreSQL, SQL Server, Oracle, H2, SQLite (and falls back to a generic dialect). The rate-limiting guide (web/sites/guides/src/content/docs/v4-0-0/digging-deeper/rate-limiting.mdx) should state which engines are supported/CI-verified for database storage and what the fallback behavior is.
Follow-up scope deliberately deferred from the database-backed rate-limit enforcement fix (internal framework review 2026-06-09, finding T8). The fix made
storage="database"actually enforce on every engine; these items close the remaining (bounded, disclosed) gaps.1. Dialect-aware atomic upsert for the fixedWindow first-insert race
$dbIncrement()(vendor/wheels/middleware/RateLimiter.cfc) uses UPDATE-first + SELECT MAX + insert-on-miss + one re-read. Concurrent first requests for the same key/window can each insert a row, producing a bounded one-time-per-window undercount (at most N-1 extra requests for N racing nodes, once per window). A real fix is a per-dialect atomic upsert:INSERT ... ON DUPLICATE KEY UPDATE counter = counter + 1INSERT ... ON CONFLICT (store_key) DO UPDATE SET counter = wheels_rate_limits.counter + 1MERGE(withHOLDLOCK) or theUPDATE-then-INSERTretry-on-2627 idiomMERGE INTOAll of these require a UNIQUE index on
store_key, which item 2 currently blocks.2. Schema redesign with a row-type discriminator
A
UNIQUE(store_key)index cannot be added today because the slidingWindow strategy inserts one row per request into the samewheels_rate_limitstable (counter rows and event rows share the schema). Proposed: add a row-type discriminator column (e.g.row_typeincounter|event|bucket) and scope uniqueness to counter/bucket rows (partial/filtered unique index where supported, or split tables). This is a schema migration for existingwheels_rate_limitstables, so it needs an upgrade path (the table is auto-created, so probe-and-alter or rename-and-recreate are both viable).3. Locking for DB tokenBucket / slidingWindow read-modify-write
$dbTokenBucket()and$dbSlidingWindow()do unguarded read-modify-write sequences; the in-processcflockonly serializes within one node, so multi-node deployments can interleave (token over-grant / event-count skew). Options:SELECT ... FOR UPDATEwhere supported, optimistic concurrency via a version column, or single-statement atomic forms (UPDATE ... SET tokens = LEAST(...)).Neither residual race is a sustainable attacker bypass — the undercount is bounded and once-per-window — but they should be closed for strict multi-node accounting.
4. Document engine support for storage="database"
$detectDatabaseType()maps MySQL, PostgreSQL, SQL Server, Oracle, H2, SQLite (and falls back to a generic dialect). The rate-limiting guide (web/sites/guides/src/content/docs/v4-0-0/digging-deeper/rate-limiting.mdx) should state which engines are supported/CI-verified for database storage and what the fallback behavior is.