From fceb040ef0579c4fc042549159a897beb7cc8d09 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 10 Mar 2026 19:39:29 -0300 Subject: [PATCH] Fix formations stability and socket reliability issues formation_overlay.js: - Fix Map mutation during iteration causing infinite loops - Use array snapshots for _updateAllFormations and clearAllFormations - Add guards to skip unnecessary RAF work when no formations exist - Simplify range change detection with primitive comparison Formations.py: - Fix DataCache method names to match actual API - Use insert_row_into_datacache, modify_datacache_item, remove_row_from_datacache communication.js: - Enable persistent reconnection (Infinity attempts) - Add bounded queue (250 max) to prevent memory growth - Coalesce candle_data messages (keep latest only) - Prioritize control/CRUD messages in queue flush formations.js: - Add user feedback alert when deleting while offline Co-Authored-By: Claude Opus 4.5 --- src/Formations.py | 28 +++++++++--------- src/static/communication.js | 51 +++++++++++++++++++++++++++++---- src/static/formation_overlay.js | 41 +++++++++++++++----------- src/static/formations.js | 3 ++ 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/src/Formations.py b/src/Formations.py index 67fb780..9ff923f 100644 --- a/src/Formations.py +++ b/src/Formations.py @@ -211,12 +211,11 @@ class Formations: now ) - # Insert into database via DataCache - self.data_cache.add_row_to_datacache( + # Insert into database/cache via DataCache + self.data_cache.insert_row_into_datacache( cache_name='formations', - row_data=dict(zip(columns, values)), - tbl_key=tbl_key, - persist=True + columns=columns, + values=values ) # Create Formation object and add to memory cache @@ -295,12 +294,14 @@ class Formations: formation.updated_at = now - # Update in database - self.data_cache.update_row_in_datacache( + # Update in database/cache + self.data_cache.modify_datacache_item( cache_name='formations', - tbl_key=tbl_key, - updates=update_data, - persist=True + filter_vals=[('tbl_key', tbl_key)], + field_names=tuple(update_data.keys()), + new_values=tuple(update_data.values()), + key=tbl_key, + overwrite='tbl_key' ) logger.info(f"Updated formation '{formation.name}' (tbl_key: {tbl_key})") @@ -333,11 +334,10 @@ class Formations: if formation.user_id != user_id: return {"success": False, "message": "Not authorized to delete this formation"} - # Remove from database - self.data_cache.delete_row_from_datacache( + # Remove from database/cache + self.data_cache.remove_row_from_datacache( cache_name='formations', - tbl_key=tbl_key, - persist=True + filter_vals=[('tbl_key', tbl_key)] ) # Remove from memory cache diff --git a/src/static/communication.js b/src/static/communication.js index ca81baf..0a4dd6a 100644 --- a/src/static/communication.js +++ b/src/static/communication.js @@ -16,6 +16,7 @@ class Comms { // Initialize the message queue this.messageQueue = []; + this.maxQueueSize = 250; // Save the userName this.userName = userName; @@ -62,8 +63,9 @@ class Comms { query: { 'user_name': this.userName }, transports: ['websocket'], // Optional: Force WebSocket transport autoConnect: true, - reconnectionAttempts: 5, // Optional: Number of reconnection attempts - reconnectionDelay: 1000 // Optional: Delay between reconnections + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000 }); // Handle connection events @@ -118,8 +120,24 @@ class Comms { * Flushes the message queue by sending all queued messages. */ _flushMessageQueue() { - while (this.messageQueue.length > 0) { - const { messageType, data } = this.messageQueue.shift(); + // Prioritize control/CRUD messages first, keep only the latest candle_data. + const queued = this.messageQueue.splice(0); + const priority = []; + let latestCandleMessage = null; + + queued.forEach((msg) => { + if (msg.messageType === 'candle_data') { + latestCandleMessage = msg; + } else { + priority.push(msg); + } + }); + + const messagesToSend = latestCandleMessage + ? [...priority, latestCandleMessage] + : priority; + + messagesToSend.forEach(({ messageType, data }) => { this.socket.emit('message', { message_type: messageType, data: { @@ -128,6 +146,29 @@ class Comms { } }); console.log(`Comms: Sent queued message-> ${JSON.stringify({ messageType, data })}`); + }); + } + + _enqueueMessage(messageType, data) { + if (messageType === 'candle_data') { + // Candle data is high-frequency. Keep only the latest unsent candle update. + const existingIndex = this.messageQueue.findIndex(msg => msg.messageType === 'candle_data'); + if (existingIndex !== -1) { + this.messageQueue[existingIndex] = { messageType, data }; + return; + } + } + + this.messageQueue.push({ messageType, data }); + + // Prevent unbounded queue growth while disconnected. + while (this.messageQueue.length > this.maxQueueSize) { + const candleIndex = this.messageQueue.findIndex(msg => msg.messageType === 'candle_data'); + if (candleIndex !== -1) { + this.messageQueue.splice(candleIndex, 1); + } else { + this.messageQueue.shift(); + } } } @@ -468,7 +509,7 @@ class Comms { // Not an error; message will be queued console.warn('Socket.IO connection is not open. Queuing message.'); // Queue the message to be sent once connected - this.messageQueue.push({ messageType, data }); + this._enqueueMessage(messageType, data); console.warn(`Comms: Queued message-> ${JSON.stringify({ messageType, data })} (Connection not open)`); } } diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js index 2092220..1ebdba5 100644 --- a/src/static/formation_overlay.js +++ b/src/static/formation_overlay.js @@ -34,7 +34,6 @@ class FormationOverlay { this._loopRunning = false; this._animationFrameId = null; this._lastTimeRange = null; - this._lastPriceRange = null; // Callback for saving formations this.onSaveCallback = null; @@ -116,9 +115,12 @@ class FormationOverlay { const sync = () => { if (!this._loopRunning) return; - // Check if chart view has changed - if (this._hasViewChanged()) { - this._updateAllFormations(); + // Skip work when there's nothing to redraw. + if (this.renderedFormations.size > 0 || this.drawingMode) { + // Check if chart view has changed + if (this._hasViewChanged()) { + this._updateAllFormations(); + } } this._animationFrameId = requestAnimationFrame(sync); @@ -138,15 +140,20 @@ class FormationOverlay { try { const timeScale = this.chart.timeScale(); const timeRange = timeScale.getVisibleLogicalRange(); - // In v5, use barsInLogicalRange for visible bars info - const visibleBars = this.candleSeries ? this.candleSeries.barsInLogicalRange(timeRange) : null; + if (!timeRange) return false; - const timeChanged = JSON.stringify(timeRange) !== JSON.stringify(this._lastTimeRange); - const barsChanged = JSON.stringify(visibleBars) !== JSON.stringify(this._lastPriceRange); + // Store only stable primitives for quick equality checks. + const nextRange = { + from: Number(timeRange.from), + to: Number(timeRange.to) + }; - if (timeChanged || barsChanged) { - this._lastTimeRange = timeRange; - this._lastPriceRange = visibleBars; + const changed = !this._lastTimeRange + || nextRange.from !== this._lastTimeRange.from + || nextRange.to !== this._lastTimeRange.to; + + if (changed) { + this._lastTimeRange = nextRange; return true; } } catch (e) { @@ -678,10 +685,10 @@ class FormationOverlay { * Update all rendered formations (called when chart view changes). */ _updateAllFormations() { - for (const [tblKey, data] of this.renderedFormations) { - // Re-render the formation - this.renderFormation(data.formation); - } + // IMPORTANT: iterate over a snapshot, because renderFormation mutates renderedFormations. + // Mutating a Map while iterating it can lead to effectively infinite loops. + const formationsSnapshot = Array.from(this.renderedFormations.values()).map(item => item.formation); + formationsSnapshot.forEach(formation => this.renderFormation(formation)); // Update temp elements if drawing if (this.drawingMode && this.currentPoints.length > 0) { @@ -714,7 +721,9 @@ class FormationOverlay { * Clear all formations from the overlay. */ clearAllFormations() { - for (const tblKey of this.renderedFormations.keys()) { + // Iterate over a stable key snapshot to avoid mutating the Map during iteration. + const keys = Array.from(this.renderedFormations.keys()); + for (const tblKey of keys) { this.removeFormation(tblKey); } } diff --git a/src/static/formations.js b/src/static/formations.js index 47b1d3b..ee706c3 100644 --- a/src/static/formations.js +++ b/src/static/formations.js @@ -519,6 +519,9 @@ class Formations { } if (this.comms) { + if (!this.comms.connectionOpen) { + alert('Server connection is offline. Delete will be sent after reconnect.'); + } this.comms.sendToApp('delete_formation', { tbl_key: tblKey }); } }