mirror of
https://github.com/aculix/ticket-monitoring.git
synced 2026-10-27 07:15:51 +00:00
fix: re-alert when withdrawn shows are re-listed
District published Endgame showtimes, the monitor alerted, and then the shows were pulled. Dedupe remembered every session id it had announced, and re-listings often reuse the same ids, so the moment bookings actually opened would have been silent. It only re-armed this time because a redeploy happened to wipe the state file. A session missing for three consecutive successful checks is now forgotten, so its return alerts again. Three rather than one, so a flaky response can't produce a duplicate max-priority push; failed checks don't count. When the whole date goes dark after opening, a separate notice says the shows were withdrawn. The re-arm lives in the base state rather than that notice's patch, so a lost notification can't leave the monitor disarmed.
This commit is contained in:
1 parent
ef23f25ee5
commit
474415bb55
4 files changed
+157
-6
No files matched your search
@@ -28,12 +28,14 @@ daily heartbeat. A monitor that died at 3am and one that's quietly working shoul
|
||||
same from the outside.
|
||||
|
||||
Alerts are deduplicated by session id, so you hear about each show once. Shows added later still
|
||||
get through.
|
||||
get through, and so do shows that were pulled and re-listed: a show missing for three checks in a
|
||||
row is forgotten, so its return alerts you again.
|
||||
|
||||
| Notification | Priority | What it means |
|
||||
|---|---|---|
|
||||
| `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
|
||||
| `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
|
||||
| `<DATE> shows withdrawn on <SITE>` | default | Shows you were alerted about were pulled. Re-armed for their return. |
|
||||
| `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. |
|
||||
| `<SITE> checks recovered` | default | Back to normal. |
|
||||
| `Still watching <DATE>` | min | Daily heartbeat, with a line per site. |
|
||||
@@ -253,7 +255,7 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # 24 tests, no network needed
|
||||
npm test # 35 tests, no network needed
|
||||
```
|
||||
|
||||
The tests run the alert state machine and both site parsers against real captured API responses
|
||||
|
||||
@@ -75,6 +75,14 @@ edge case testable without network or clock mocking, and it's where the interest
|
||||
alert's push succeeds. A failed notification retries on the next tick instead of being silently
|
||||
recorded as delivered.
|
||||
- **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you.
|
||||
- **Dedupe does not outlive the listing.** Cinemas publish early, pull the shows and re-list
|
||||
them, often under the same session ids, and this really happened with the Endgame release. A
|
||||
show missing for three consecutive successful checks is forgotten, so its return alerts again.
|
||||
Three rather than one, so a single flaky response can't cause a duplicate max-priority push,
|
||||
and failed checks don't count, since an outage says nothing about whether a show exists. If
|
||||
the whole date goes dark after opening, a separate notice says the shows were withdrawn. The
|
||||
re-arm itself lives in the base state, not in that notice's patch, so it happens even if the
|
||||
notice fails to deliver.
|
||||
- **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push
|
||||
followed by sessions disappearing could swallow the only notification you'd ever get.
|
||||
- **Open-but-empty counts as closed**, defending against a `200` with no sessions.
|
||||
|
||||
+55
-4
@@ -2,8 +2,18 @@
|
||||
// Providers supply normalized sessions; nothing here knows which site they came from.
|
||||
import { localDate, localMinutes } from "./lib.js";
|
||||
|
||||
/**
|
||||
* Consecutive successful checks a previously announced show must be absent before it is
|
||||
* forgotten. Counting checks, rather than reacting to one miss, stops a single flaky
|
||||
* response from turning into a duplicate max-priority push.
|
||||
*/
|
||||
export const REARM_AFTER = 3;
|
||||
|
||||
export function defaultProviderState() {
|
||||
return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null };
|
||||
return {
|
||||
dateOpenAlerted: false, sidsAlerted: {}, missing: {}, closedTicks: 0,
|
||||
failures: 0, brokenAlerted: false, lastGood: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultState() {
|
||||
@@ -78,12 +88,53 @@ export function decideProvider(pstate, input, cfg, p) {
|
||||
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, note, at: now.toISOString() };
|
||||
}
|
||||
|
||||
// Cinemas publish early, pull the shows and re-list them, often under the same session
|
||||
// ids. Dedupe must not outlive the listing, or the re-listing is silent. So a show that
|
||||
// stays missing for REARM_AFTER checks is forgotten, and its return alerts again. This
|
||||
// lives in the base state rather than an alert patch: re-arming must not depend on a
|
||||
// notification getting through.
|
||||
const present = new Set(effectiveKind === "open" ? ex.matched.map((s) => s.sid) : []);
|
||||
const alerted = { ...(pstate.sidsAlerted ?? {}) };
|
||||
const missing = { ...(pstate.missing ?? {}) };
|
||||
for (const sid of Object.keys(alerted)) {
|
||||
if (present.has(sid)) {
|
||||
delete missing[sid];
|
||||
} else if ((missing[sid] = (missing[sid] ?? 0) + 1) >= REARM_AFTER) {
|
||||
delete alerted[sid];
|
||||
delete missing[sid];
|
||||
}
|
||||
}
|
||||
for (const sid of Object.keys(missing)) if (!(sid in alerted)) delete missing[sid];
|
||||
base.sidsAlerted = alerted;
|
||||
base.missing = missing;
|
||||
|
||||
// The whole date going dark after it opened is worth saying out loud: the user may be
|
||||
// mid-booking and wondering where the shows went. Counted the same way, then re-armed.
|
||||
if (effectiveKind === "closed" && pstate.dateOpenAlerted) {
|
||||
base.closedTicks = (pstate.closedTicks ?? 0) + 1;
|
||||
if (base.closedTicks >= REARM_AFTER) {
|
||||
base.dateOpenAlerted = false;
|
||||
base.closedTicks = 0;
|
||||
alerts.push(tag({
|
||||
title: `${cfg.targetDate} shows withdrawn on ${p.label}`,
|
||||
priority: "3",
|
||||
tags: "arrows_counterclockwise",
|
||||
click: p.bookingUrl,
|
||||
body: `${p.label} is no longer listing shows for ${cfg.targetDate}. The monitor has re-armed, so you'll get a max-priority push again the moment they come back.`,
|
||||
statePatch: {},
|
||||
}));
|
||||
}
|
||||
} else if (pstate.closedTicks) {
|
||||
base.closedTicks = 0;
|
||||
}
|
||||
|
||||
if (effectiveKind === "open") {
|
||||
const fresh = ex.matched.filter((s) => !pstate.sidsAlerted?.[s.sid]);
|
||||
const fresh = ex.matched.filter((s) => !alerted[s.sid]);
|
||||
if (fresh.length > 0) {
|
||||
const lines = fresh.map(sessionLine);
|
||||
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
|
||||
const sidsAlerted = { ...pstate.sidsAlerted };
|
||||
// Built from the post-forget map, so the patch can't resurrect a forgotten id.
|
||||
const sidsAlerted = { ...alerted };
|
||||
for (const s of fresh) sidsAlerted[s.sid] = true;
|
||||
alerts.push(tag({
|
||||
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`,
|
||||
@@ -93,7 +144,7 @@ export function decideProvider(pstate, input, cfg, p) {
|
||||
body: lines.join("\n\n"),
|
||||
statePatch: { sidsAlerted, dateOpenAlerted: true },
|
||||
}));
|
||||
} else if (!pstate.dateOpenAlerted && ex.matched.length === 0) {
|
||||
} else if (!base.dateOpenAlerted && ex.matched.length === 0) {
|
||||
alerts.push(tag({
|
||||
title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`,
|
||||
priority: "4",
|
||||
|
||||
@@ -212,3 +212,93 @@ test("clearing the note is itself a state change, so the status endpoint stops l
|
||||
const after = decideProvider(withNote, { kind: "closed", now: T0 }, CFG, P);
|
||||
assert.equal(after.baseState.lastGood.note, null, "note is dropped once the listing appears");
|
||||
});
|
||||
|
||||
// ------------------------------------------------ withdrawn and re-listed shows
|
||||
//
|
||||
// Cinemas publish early, pull the shows, then re-list them, often under the same
|
||||
// session ids. If dedupe outlives the listing, the re-listing (the moment bookings
|
||||
// really open) is silent. These pin down when a vanished session is forgotten.
|
||||
|
||||
const closed = (now = T0) => ({ kind: "closed", now });
|
||||
const tick = (state, input, p = P) => apply(state, decideProvider(state, input, CFG, p));
|
||||
|
||||
test("a withdrawn session that comes back after 3 missed checks alerts again", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
assert.ok(s.sidsAlerted["111"], "alerted once");
|
||||
for (let i = 0; i < 3; i++) s = tick(s, closed());
|
||||
assert.equal(s.sidsAlerted["111"], undefined, "forgotten after 3 checks without it");
|
||||
|
||||
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
|
||||
assert.equal(back.alerts.length, 1, "the re-listing must alert");
|
||||
assert.equal(back.alerts[0].priority, "5");
|
||||
});
|
||||
|
||||
test("a session missing for fewer than 3 checks is not re-alerted", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
s = tick(s, closed());
|
||||
s = tick(s, closed());
|
||||
const back = decideProvider(s, openInput(EX_MATCH), CFG, P);
|
||||
assert.equal(back.alerts.length, 0, "a brief flicker in the response is not a new listing");
|
||||
assert.deepEqual(apply(s, back).missing, {}, "and the miss counter resets once it is seen again");
|
||||
});
|
||||
|
||||
test("a fully withdrawn date re-arms after 3 closed checks and says so, once", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
assert.equal(s.dateOpenAlerted, true);
|
||||
s = tick(s, closed());
|
||||
s = tick(s, closed());
|
||||
const third = decideProvider(s, closed(), CFG, P);
|
||||
const notice = third.alerts.find((a) => /withdrawn/i.test(a.title));
|
||||
assert.ok(notice, "the user should hear that the shows were pulled");
|
||||
assert.ok(notice.title.includes("District"));
|
||||
assert.ok(Number(notice.priority) < 5, "informational, not a booking alert");
|
||||
s = apply(s, third);
|
||||
assert.equal(s.dateOpenAlerted, false, "re-armed");
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
assert.equal(decideProvider(s, closed(), CFG, P).alerts.length, 0, "the notice is not repeated");
|
||||
s = tick(s, closed());
|
||||
}
|
||||
});
|
||||
|
||||
test("the re-arm does not depend on the withdrawal notice being delivered", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
s = tick(s, closed());
|
||||
s = tick(s, closed());
|
||||
const third = decideProvider(s, closed(), CFG, P);
|
||||
s = third.baseState; // ntfy was down: no alert patches merged
|
||||
assert.equal(s.dateOpenAlerted, false, "re-arming is not gated on the push");
|
||||
assert.equal(s.sidsAlerted["111"], undefined);
|
||||
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts[0].priority, "5",
|
||||
"so a re-listing still gets through even if the notice was lost");
|
||||
});
|
||||
|
||||
test("partial withdrawal forgets only the missing session, with no withdrawal notice", () => {
|
||||
const two = { ...EX_MATCH, matched: [EX_MATCH.matched[0], { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
|
||||
let s = tick(defaultProviderState(), openInput(two));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = decideProvider(s, openInput(EX_MATCH), CFG, P); // 222 gone, date still open
|
||||
assert.equal(d.alerts.filter((a) => /withdrawn/i.test(a.title)).length, 0);
|
||||
s = apply(s, d);
|
||||
}
|
||||
assert.ok(s.sidsAlerted["111"], "the session still listed stays deduped");
|
||||
assert.equal(s.sidsAlerted["222"], undefined, "the pulled one is forgotten");
|
||||
|
||||
const back = decideProvider(s, openInput(two), CFG, P);
|
||||
assert.equal(back.alerts.length, 1);
|
||||
assert.ok(back.alerts[0].body.includes("1:15 PM"), "only the returning session is announced");
|
||||
assert.ok(!back.alerts[0].body.includes("9:00 AM"));
|
||||
});
|
||||
|
||||
test("failed checks do not count as a session being missing", () => {
|
||||
let s = tick(defaultProviderState(), openInput(EX_MATCH));
|
||||
for (let i = 0; i < 5; i++) s = tick(s, { kind: "error", reason: "HTTP 403", now: T0 });
|
||||
assert.ok(s.sidsAlerted["111"], "an outage says nothing about whether the show still exists");
|
||||
assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts.filter((a) => a.priority === "5").length, 0);
|
||||
});
|
||||
|
||||
test("waiting on a date that never opened writes no state", () => {
|
||||
let s = tick(defaultProviderState(), closed());
|
||||
const next = decideProvider(s, closed(), CFG, P);
|
||||
assert.equal(JSON.stringify(apply(s, next)), JSON.stringify(s), "no counters ticking while nothing was ever alerted");
|
||||
});
|
||||
Reference in new issue
Block a user