//+------------------------------------------------------------------+ //| 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