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);