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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-10 19:39:29 -03:00
parent 5717ac6a81
commit fceb040ef0
4 changed files with 88 additions and 35 deletions

View File

@ -211,12 +211,11 @@ class Formations:
now now
) )
# Insert into database via DataCache # Insert into database/cache via DataCache
self.data_cache.add_row_to_datacache( self.data_cache.insert_row_into_datacache(
cache_name='formations', cache_name='formations',
row_data=dict(zip(columns, values)), columns=columns,
tbl_key=tbl_key, values=values
persist=True
) )
# Create Formation object and add to memory cache # Create Formation object and add to memory cache
@ -295,12 +294,14 @@ class Formations:
formation.updated_at = now formation.updated_at = now
# Update in database # Update in database/cache
self.data_cache.update_row_in_datacache( self.data_cache.modify_datacache_item(
cache_name='formations', cache_name='formations',
tbl_key=tbl_key, filter_vals=[('tbl_key', tbl_key)],
updates=update_data, field_names=tuple(update_data.keys()),
persist=True new_values=tuple(update_data.values()),
key=tbl_key,
overwrite='tbl_key'
) )
logger.info(f"Updated formation '{formation.name}' (tbl_key: {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: if formation.user_id != user_id:
return {"success": False, "message": "Not authorized to delete this formation"} return {"success": False, "message": "Not authorized to delete this formation"}
# Remove from database # Remove from database/cache
self.data_cache.delete_row_from_datacache( self.data_cache.remove_row_from_datacache(
cache_name='formations', cache_name='formations',
tbl_key=tbl_key, filter_vals=[('tbl_key', tbl_key)]
persist=True
) )
# Remove from memory cache # Remove from memory cache

View File

@ -16,6 +16,7 @@ class Comms {
// Initialize the message queue // Initialize the message queue
this.messageQueue = []; this.messageQueue = [];
this.maxQueueSize = 250;
// Save the userName // Save the userName
this.userName = userName; this.userName = userName;
@ -62,8 +63,9 @@ class Comms {
query: { 'user_name': this.userName }, query: { 'user_name': this.userName },
transports: ['websocket'], // Optional: Force WebSocket transport transports: ['websocket'], // Optional: Force WebSocket transport
autoConnect: true, autoConnect: true,
reconnectionAttempts: 5, // Optional: Number of reconnection attempts reconnection: true,
reconnectionDelay: 1000 // Optional: Delay between reconnections reconnectionAttempts: Infinity,
reconnectionDelay: 1000
}); });
// Handle connection events // Handle connection events
@ -118,8 +120,24 @@ class Comms {
* Flushes the message queue by sending all queued messages. * Flushes the message queue by sending all queued messages.
*/ */
_flushMessageQueue() { _flushMessageQueue() {
while (this.messageQueue.length > 0) { // Prioritize control/CRUD messages first, keep only the latest candle_data.
const { messageType, data } = this.messageQueue.shift(); 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', { this.socket.emit('message', {
message_type: messageType, message_type: messageType,
data: { data: {
@ -128,6 +146,29 @@ class Comms {
} }
}); });
console.log(`Comms: Sent queued message-> ${JSON.stringify({ messageType, data })}`); 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 // Not an error; message will be queued
console.warn('Socket.IO connection is not open. Queuing message.'); console.warn('Socket.IO connection is not open. Queuing message.');
// Queue the message to be sent once connected // 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)`); console.warn(`Comms: Queued message-> ${JSON.stringify({ messageType, data })} (Connection not open)`);
} }
} }

View File

@ -34,7 +34,6 @@ class FormationOverlay {
this._loopRunning = false; this._loopRunning = false;
this._animationFrameId = null; this._animationFrameId = null;
this._lastTimeRange = null; this._lastTimeRange = null;
this._lastPriceRange = null;
// Callback for saving formations // Callback for saving formations
this.onSaveCallback = null; this.onSaveCallback = null;
@ -116,10 +115,13 @@ class FormationOverlay {
const sync = () => { const sync = () => {
if (!this._loopRunning) return; if (!this._loopRunning) return;
// Skip work when there's nothing to redraw.
if (this.renderedFormations.size > 0 || this.drawingMode) {
// Check if chart view has changed // Check if chart view has changed
if (this._hasViewChanged()) { if (this._hasViewChanged()) {
this._updateAllFormations(); this._updateAllFormations();
} }
}
this._animationFrameId = requestAnimationFrame(sync); this._animationFrameId = requestAnimationFrame(sync);
}; };
@ -138,15 +140,20 @@ class FormationOverlay {
try { try {
const timeScale = this.chart.timeScale(); const timeScale = this.chart.timeScale();
const timeRange = timeScale.getVisibleLogicalRange(); const timeRange = timeScale.getVisibleLogicalRange();
// In v5, use barsInLogicalRange for visible bars info if (!timeRange) return false;
const visibleBars = this.candleSeries ? this.candleSeries.barsInLogicalRange(timeRange) : null;
const timeChanged = JSON.stringify(timeRange) !== JSON.stringify(this._lastTimeRange); // Store only stable primitives for quick equality checks.
const barsChanged = JSON.stringify(visibleBars) !== JSON.stringify(this._lastPriceRange); const nextRange = {
from: Number(timeRange.from),
to: Number(timeRange.to)
};
if (timeChanged || barsChanged) { const changed = !this._lastTimeRange
this._lastTimeRange = timeRange; || nextRange.from !== this._lastTimeRange.from
this._lastPriceRange = visibleBars; || nextRange.to !== this._lastTimeRange.to;
if (changed) {
this._lastTimeRange = nextRange;
return true; return true;
} }
} catch (e) { } catch (e) {
@ -678,10 +685,10 @@ class FormationOverlay {
* Update all rendered formations (called when chart view changes). * Update all rendered formations (called when chart view changes).
*/ */
_updateAllFormations() { _updateAllFormations() {
for (const [tblKey, data] of this.renderedFormations) { // IMPORTANT: iterate over a snapshot, because renderFormation mutates renderedFormations.
// Re-render the formation // Mutating a Map while iterating it can lead to effectively infinite loops.
this.renderFormation(data.formation); const formationsSnapshot = Array.from(this.renderedFormations.values()).map(item => item.formation);
} formationsSnapshot.forEach(formation => this.renderFormation(formation));
// Update temp elements if drawing // Update temp elements if drawing
if (this.drawingMode && this.currentPoints.length > 0) { if (this.drawingMode && this.currentPoints.length > 0) {
@ -714,7 +721,9 @@ class FormationOverlay {
* Clear all formations from the overlay. * Clear all formations from the overlay.
*/ */
clearAllFormations() { 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); this.removeFormation(tblKey);
} }
} }

View File

@ -519,6 +519,9 @@ class Formations {
} }
if (this.comms) { 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 }); this.comms.sendToApp('delete_formation', { tbl_key: tblKey });
} }
} }