From a13a54bc3f207f9e04007de3848d00b078a67ba6 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 06:30:56 -0500 Subject: [PATCH] Drop stale bar events instead of letting the chart throw Cannot update oldest data appeared in the console once ticks were live. Switching timeframe races: the server answers subscribe with a fresh snapshot from one coroutine while another is still draining bar events for the timeframe just left, so a 1m bar can arrive after the 1h snapshot. Against the 1h series it is older than every point in it, and Lightweight Charts throws rather than ignoring it, which takes the app down instead of dropping one bar. The race predates the tick feed. Level 1 made bar events about fifteen times more frequent, which is what surfaced it. Guarded at both ends. app.js honours the tf each event already carries and drops anything for a timeframe no longer selected. chart.js refuses a bar older than the series' last point whatever its origin, since a bar behind the last one has nothing to contribute. Verified: 36 rapid timeframe switches under a live tick feed produce zero console errors, and calling candles.update() directly with a stale bar still throws while the guarded updateBar() does not. Co-Authored-By: Claude Opus 5 --- docs/IMPLEMENTATION_PLAN.md | 17 +++++++++++++++++ static/app.js | 6 ++++++ static/chart.js | 6 ++++++ 3 files changed, 29 insertions(+) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index da9f0e2..725230b 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1322,3 +1322,20 @@ cooldowns, so a deploy can re-alert whatever price is sitting on. "warm" means to every consumer of `/api/status`, so it wants its own thought. Recommended order: 1, then 3, then 2. 4 only if the window still bites after 1. + +**Stale bar events across a timeframe switch.** `Cannot update oldest data` +appeared in the console once ticks were live. Switching timeframe races: the +server answers `subscribe` with a fresh snapshot from one coroutine while +another is still draining bar events for the timeframe just left, so a 1m bar +can land after the 1h snapshot. Applied to the 1h series it is older than every +point in it, and Lightweight Charts throws rather than ignoring it — taking the +app down instead of dropping one bar. The race predates the tick feed; Level 1 +made bar events ~15x more frequent, which is what surfaced it. + +Guarded at both ends. `app.js` honours the `tf` the event already carries and +drops anything for a timeframe that is no longer selected. `chart.js` refuses a +bar older than the series' last point regardless of where it came from — a bar +behind the last one has nothing to contribute. Verified: 36 rapid timeframe +switches under a live tick feed produce zero errors, and calling +`candles.update()` directly with a stale bar still throws while the guarded +`updateBar()` does not. diff --git a/static/app.js b/static/app.js index 5beeeaf..0af95ea 100644 --- a/static/app.js +++ b/static/app.js @@ -129,6 +129,12 @@ createApp({ syncVisibleLevels(); price.value = message.price; } else if (message.type === 'bar') { + // Switching timeframe races: the server answers `subscribe` with a + // fresh snapshot from one coroutine while another is still draining + // bar events for the timeframe just left. A 1m bar applied over a 1h + // series is older than everything in it, which the chart rejects + // outright. The event carries its timeframe, so honour it. + if (message.tf && message.tf !== timeframe.value) return; chartApi.updateBar(message.bar); price.value = message.bar.c; status.value.last_bar_t = message.bar.t; diff --git a/static/chart.js b/static/chart.js index 81a1ef7..6a3ecb9 100644 --- a/static/chart.js +++ b/static/chart.js @@ -165,6 +165,12 @@ class ConfluenceChart { } updateBar(bar) { + // update() throws on anything older than the series' last point, which + // takes the whole app down rather than dropping one stale bar. Ticks made + // this reachable often enough to matter, so refuse it here as well as at + // the source: a bar behind the last one has nothing to contribute. + const last = this.bars[this.bars.length - 1]; + if (last && bar.t < last.t) return; this.candles.update(this.toCandle(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar);