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

View File

@ -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)`);
}
}

View File

@ -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,10 +115,13 @@ class FormationOverlay {
const sync = () => {
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
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);
}
}

View File

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