fix(engagement): two defects the Phase 11b live walk found in core
Both are invisible to a fixture and loud on a real database, which is why the walk is the phase's acceptance rather than a formality. 1. registerEngagementSeeds validated `max_sends_per_hour` and then dropped it from the normalized rule. The column is NOT NULL, so every one of the 25 module-seeded rules failed to insert at boot. The registry test asserted the REJECTION of a bad ceiling and never that a good one survives; it now asserts the normalized rule against `engagementRules.db.insert`'s own column list, so the next field added is covered the day it is added. 2. The cooldown claim runs inside the engine's per-channel loop and its key was (rule, user, subject). So the first channel of a rule claimed the cooldown and every later one was reported as cooled -- and `inapp` is ranked first deliberately, so a rule naming email + in-app delivered the inbox item and silently never the mail. Core's own `news.post` rule has that shape. Phase 11b's decision 8 requires the letter and the inbox item to fire together. `channel` joins the PRIMARY KEY (the org lead's decision 12: a cooldown is per delivery, not per occasion). Migrated in place behind an information_schema guard, because MariaDB has no conditional form of a key change and replaying schema.sql would otherwise fail on every boot after the first. 1551 core tests green; both new tests verified by reverting each fix in turn. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1774,9 +1774,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
|
||||
-- per subject".
|
||||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||||
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
|
||||
-- Without it a rule naming two channels delivers on exactly ONE of them: the
|
||||
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
|
||||
-- purpose (so push can reference its inbox row), and every later channel is
|
||||
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
|
||||
-- inbox item to fire together, so the cooldown is per delivery, not per
|
||||
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
|
||||
-- the channel set is data a module can extend.
|
||||
channel VARCHAR(32) NOT NULL DEFAULT '',
|
||||
last_fired_at DATETIME NOT NULL,
|
||||
fire_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (rule_id, user_id, subject_key),
|
||||
PRIMARY KEY (rule_id, user_id, subject_key, channel),
|
||||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- So a prune worker can drop rows older than the longest configured cooldown.
|
||||
@@ -1785,6 +1794,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||||
INDEX idx_engc_sweep (last_fired_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Widen the key on a deployment that already has the table. Two statements, and
|
||||
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
|
||||
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
|
||||
-- carries the new one is an error, not a no-op, so replaying this file on every
|
||||
-- boot would fail the whole schema after the first run. The guard reads the key
|
||||
-- itself out of information_schema rather than the column's existence, because
|
||||
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
|
||||
--
|
||||
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
|
||||
-- subject) that expires on its own interval. That is the right trade against
|
||||
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
|
||||
-- notification, and dropping the table would let a bounce storm through.
|
||||
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
|
||||
SET @engc_key_has_channel := (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
|
||||
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
|
||||
);
|
||||
SET @sql := IF(@engc_key_has_channel = 0,
|
||||
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
|
||||
-- (rule, user, channel) occurrence of an event.
|
||||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||||
|
||||
Reference in New Issue
Block a user