forked from animatedread/Warrior_EA
A DATA-INTEGRITY BUG, pre-existing, surfaced by the clearer failure message in
869cd1b putting two identical timestamps next to each other:
13:42:36.584 (EURUSD) AltRawSave: atomic rename raw_EIA_WPSR.csv.savetmp -> ... failed
13:42:36.584 (XTIUSD) AltRawSave: atomic rename raw_EIA_WPSR.csv.savetmp -> ... failed
Same file, same millisecond, two charts, a third winning the race. That is not
reader/writer contention - it is THREE WRITERS on one destination, and
AtomicWriteBegin derived the staging name from the destination alone:
tmpName = finalName + ".savetmp"
So all three opened the SAME temp with FILE_WRITE and wrote it from offset 0 at
once. The published file could be an interleaved mixture of two charts' output,
and the atomic rename publishes that mixture faithfully - the swap guarantees a
reader never sees a HALF-WRITTEN file, and does nothing about a HALF-CORRECT one.
Alt-data is the exposed case: several charts fetch the same series and write the
same Common file.
Keying the temp on symbol+period makes staging private. The rename stays the only
contended operation, and a rename IS atomic, so a loser now publishes nothing
rather than half of itself. It also makes deferred promotion sound for the first
time: the temp promoted later is THIS chart's complete content, never a fragment
of someone else's.
SharedFileCopy.mqh uses the same shape but its destination is agent/terminal-local
and keyed by symbol+fingerprint, so charts cannot collide there. Left alone.
Note the two bugs are independent and both fixes are real. Confirmed in situ at
13:45:45, on the reader/writer one:
CTrainPoolWriter::Publish: atomic rename TrainPool\USDCAD_16388.bin failed (5004)
Warrior: deferred promotion of TrainPool\USDCAD_16388.bin succeeded - the peer
chart that held it has closed it, and the content written earlier is now live
without rewriting the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
160 lines
8.4 KiB
MQL5
160 lines
8.4 KiB
MQL5
//+------------------------------------------------------------------+
|
|
//| Warrior_EA |
|
|
//| AnimateDread |
|
|
//| |
|
|
//| Crash-safe file writes: temp file + atomic rename. |
|
|
//+------------------------------------------------------------------+
|
|
#ifndef WARRIOR_ATOMIC_FILE_MQH
|
|
#define WARRIOR_ATOMIC_FILE_MQH
|
|
|
|
//--- DEFERRED PROMOTION, and the in-line retry that preceded it was WRONG. A rename fails when a peer
|
|
//--- chart holds the destination open, and MQL5 has no FILE_SHARE_DELETE, so the rename simply cannot
|
|
//--- succeed for as long as that reader is open. The first attempt at this assumed a reader holds a
|
|
//--- pool file for "tens of ms" and spun 4x25ms; measured, `USDCAD_16388.bin` is 134 MB and a peer
|
|
//--- reading it holds the handle for SECONDS. The loop lost every time (`failed ... after 4 attempts`)
|
|
//--- and all it bought was 75ms of tick latency on the failure path.
|
|
//---
|
|
//--- So: try ONCE, and if the destination is busy, REMEMBER the temp and promote it from the timer.
|
|
//--- The content is already written and correct - only the swap is blocked - so once the reader closes,
|
|
//--- a single FileMove lands it. That beats waiting for the next full publish, which would re-write all
|
|
//--- 134 MB and might be an era away.
|
|
//--- THE STAGING FILE MUST BE PRIVATE TO THE WRITER. It used to be `finalName + ".savetmp"`, derived
|
|
//--- from the DESTINATION alone - which is fine for a chart-local file and a genuine data-integrity
|
|
//--- bug for a shared one in Common\Files. Several charts fetch the same alt-data series and write
|
|
//--- the same destination, so they all opened ONE temp with FILE_WRITE and wrote it from offset 0 at
|
|
//--- once; whichever renamed first published whatever mixture of two charts' output the interleaving
|
|
//--- happened to leave. Observed 2026-08-26: EURUSD and XTIUSD both failed to rename
|
|
//--- `raw_EIA_WPSR.csv` in the SAME MILLISECOND, with a third writer winning the race.
|
|
//---
|
|
//--- Keying the temp on symbol+period makes staging private: the rename stays the only contended
|
|
//--- operation, and a rename is atomic, so a loser now publishes nothing rather than half of itself.
|
|
//--- It is also what makes deferred promotion sound - the temp promoted later is THIS chart's
|
|
//--- complete content, never a fragment of someone else's.
|
|
string AtomicTempName(const string finalName)
|
|
{
|
|
return StringFormat("%s.%s_%d.savetmp", finalName, _Symbol, (int)_Period);
|
|
}
|
|
|
|
#define ATOMIC_PENDING_MAX 8
|
|
string g_atomicPendingFinal[ATOMIC_PENDING_MAX];
|
|
int g_atomicPendingFlag[ATOMIC_PENDING_MAX];
|
|
int g_atomicPendingCount = 0;
|
|
|
|
//--- Bounded and deduplicated: there is only ever one temp per (final name, THIS chart), because
|
|
//--- AtomicTempName() reuses it, so a second failure for the same file must not take a second slot.
|
|
void AtomicRememberPending(const string finalName, const int commonFlag)
|
|
{
|
|
for(int i = 0; i < g_atomicPendingCount; i++)
|
|
if(g_atomicPendingFinal[i] == finalName && g_atomicPendingFlag[i] == commonFlag)
|
|
return;
|
|
if(g_atomicPendingCount >= ATOMIC_PENDING_MAX)
|
|
return; // full: this one falls back to the next publish, as it always did
|
|
g_atomicPendingFinal[g_atomicPendingCount] = finalName;
|
|
g_atomicPendingFlag[g_atomicPendingCount] = commonFlag;
|
|
g_atomicPendingCount++;
|
|
}
|
|
|
|
void AtomicForgetPending(const string finalName, const int commonFlag)
|
|
{
|
|
for(int i = 0; i < g_atomicPendingCount; i++)
|
|
if(g_atomicPendingFinal[i] == finalName && g_atomicPendingFlag[i] == commonFlag)
|
|
{
|
|
g_atomicPendingFinal[i] = g_atomicPendingFinal[g_atomicPendingCount - 1];
|
|
g_atomicPendingFlag[i] = g_atomicPendingFlag[g_atomicPendingCount - 1];
|
|
g_atomicPendingCount--;
|
|
return;
|
|
}
|
|
}
|
|
|
|
//--- Called from the TIMER, not the tick: this is I/O, and the whole point is to stop paying for it
|
|
//--- inside a quote. Returns how many it managed to land. Cheap when there is nothing pending, which
|
|
//--- is the overwhelmingly common case.
|
|
int AtomicPromotePending(void)
|
|
{
|
|
int promoted = 0;
|
|
for(int i = g_atomicPendingCount - 1; i >= 0; i--)
|
|
{
|
|
string finalName = g_atomicPendingFinal[i];
|
|
string tmpName = AtomicTempName(finalName);
|
|
int flag = g_atomicPendingFlag[i];
|
|
//--- Gone means someone else already resolved it (a later publish succeeded outright). Drop it.
|
|
if(!FileIsExist(tmpName, flag))
|
|
{
|
|
AtomicForgetPending(finalName, flag);
|
|
continue;
|
|
}
|
|
ResetLastError();
|
|
if(!FileMove(tmpName, flag, finalName, flag | FILE_REWRITE))
|
|
continue; // still held; try again on the next timer
|
|
Print("Warrior: deferred promotion of ", finalName, " succeeded - the peer chart that held it"
|
|
" has closed it, and the content written earlier is now live without rewriting the file.");
|
|
AtomicForgetPending(finalName, flag);
|
|
promoted++;
|
|
}
|
|
return promoted;
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| Open a temp file to stage an atomic write. modeFlags defaults to |
|
|
//| FILE_BIN (every original caller wrote binary payloads); a text |
|
|
//| writer passes FILE_TXT|FILE_ANSI so FileWriteString still emits |
|
|
//| plain lines instead of length-prefixed binary records. |
|
|
//+------------------------------------------------------------------+
|
|
int AtomicWriteBegin(const string finalName, const int commonFlag, string &tmpName, const int modeFlags = FILE_BIN)
|
|
{
|
|
tmpName = AtomicTempName(finalName);
|
|
ResetLastError();
|
|
return FileOpen(tmpName, commonFlag | modeFlags | FILE_WRITE | FILE_SHARE_READ | FILE_SHARE_WRITE);
|
|
}
|
|
|
|
//+------------------------------------------------------------------+
|
|
//| Close the staged write and either publish it or discard it. |
|
|
//+------------------------------------------------------------------+
|
|
bool AtomicWriteEnd(const int handle, const string finalName, const string tmpName,
|
|
const int commonFlag, const bool ok, const string context)
|
|
{
|
|
if(handle != INVALID_HANDLE)
|
|
{
|
|
FileFlush(handle);
|
|
FileClose(handle);
|
|
}
|
|
//--- A failed/partial write must NEVER touch the real file - discard the temp, keep the last good copy.
|
|
if(!ok)
|
|
{
|
|
FileDelete(tmpName, commonFlag);
|
|
Print(context, ": write to ", tmpName, " failed - kept the existing ", finalName,
|
|
" intact (no atomic swap performed)");
|
|
return false;
|
|
}
|
|
//--- Atomic swap: FILE_REWRITE lets FileMove replace an existing destination in one operation.
|
|
//---
|
|
//--- RETRIED, BECAUSE THE COMMON FAILURE IS TRANSIENT AND NOT OURS. Six charts share the TrainPool
|
|
//--- and AltData directories, so a publish regularly lands while a PEER CHART has the destination
|
|
//--- open for reading, and the rename comes back 5004 (which MQL5 uses for both "no such file" and
|
|
//--- "locked by another process"). Measured 27 times in one day on the live fleet before this loop
|
|
//--- existed. Nothing was lost - the temp keeps the new content and the old file stays intact - but
|
|
//--- the pool row simply did not update until the next publish, which on a chronically busy
|
|
//--- directory could be a long time.
|
|
//---
|
|
//--- The sleep is on the FAILURE PATH ONLY: a successful rename does not sleep at all, so the common
|
|
//--- case costs one extra comparison. Skipped in the tester (where Sleep distorts a pass and the
|
|
//--- contention cannot happen - one process, no peers) and when the program is stopping, where
|
|
//--- Sleep returns immediately anyway and the remaining attempts should just be spent at once.
|
|
ResetLastError();
|
|
if(FileMove(tmpName, commonFlag, finalName, commonFlag | FILE_REWRITE))
|
|
{
|
|
//--- A fresh write supersedes anything queued for this name.
|
|
AtomicForgetPending(finalName, commonFlag);
|
|
return true;
|
|
}
|
|
int lastErr = GetLastError();
|
|
AtomicRememberPending(finalName, commonFlag);
|
|
Print(context, ": atomic rename ", tmpName, " -> ", finalName, " failed (error ",
|
|
IntegerToString(lastErr), ") - a peer chart is holding it open. The temp holds the new"
|
|
" content and ", finalName, " is unchanged; the timer will promote it as soon as the"
|
|
" reader closes, without rewriting the file.");
|
|
ResetLastError();
|
|
return false;
|
|
}
|
|
//+------------------------------------------------------------------+
|
|
#endif // WARRIOR_ATOMIC_FILE_MQH
|