Warrior_EA/System/AtomicFile.mqh
AnimateDread d059780c22 fix(io): stage atomic writes to a PER-CHART temp, not a shared one
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>
2026-08-26 13:48:43 -04:00

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