Skip to content

Commit f1b3381

Browse files
bmehta001Copilot
andcommitted
Prevent event loss when a disk write fails during Flush()
Combine the Flush() data-loss fix into this storage-data-safety PR (the two are halves of the same fix: this PR already makes OfflineStorage_SQLite::StoreRecord report write failures; Flush() must act on that). OfflineStorageHandler::Flush() previously drained the in-memory queue with GetRecords() (which removes records) and handed them to StoreRecords() before confirming persistence. On a partial/total disk write failure the un-persisted records were already gone from memory and never re-queued -> events lost. Flush() now drains into a local batch, persists one record at a time, and re-inserts only the records that fail to persist (so failures are retried, not lost). Per-record StoreRecord() is used deliberately: a batched StoreRecords() only returns a count, so on a partial failure we could not tell which records to re-queue, and re-storing already-saved records would duplicate them (no unique record_id constraint). Also null-guards the dbSizeBeforeFlush read so Flush() is safe with disk-only storage (CFG_INT_RAM_QUEUE_SIZE == 0). Adds OfflineStorageHandlerFlushTests.FailedDiskWriteDuringFlushReturnsRecordsToMemory (records the SQLite store rejects stay in memory after Flush; verified it fails against the previous GetRecords()-based Flush). Closes the separate PR microsoft#1496. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d9640b7 commit f1b3381

2 files changed

Lines changed: 128 additions & 17 deletions

File tree

lib/offline/OfflineStorageHandler.cpp

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -174,28 +174,44 @@ namespace MAT_NS_BEGIN {
174174
// than the handle gets replaced by nullptr in this DeferredCallbackHandle obj.
175175
m_flushHandle.Cancel();
176176

177-
size_t dbSizeBeforeFlush = m_offlineStorageMemory->GetSize();
177+
size_t dbSizeBeforeFlush = (m_offlineStorageMemory != nullptr) ? m_offlineStorageMemory->GetSize() : 0;
178178
if ((m_offlineStorageMemory) && (dbSizeBeforeFlush > 0) && (m_offlineStorageDisk))
179179
{
180-
// This will block on and then take a lock for the duration of this move, and
181-
// StoreRecord() will then block until the move completes.
180+
// Drain the in-memory queue into a local batch. Records are removed
181+
// from memory here; any that fail to persist below are re-inserted, so
182+
// a disk write failure does not silently lose events. Draining (rather
183+
// than reserving) keeps only a single copy of each record in flight and
184+
// avoids stamping a reservation lease that the Room backend would
185+
// persist to disk.
182186
auto records = m_offlineStorageMemory->GetRecords(false, EventLatency_Unspecified);
183-
std::vector<StorageRecordId> ids;
184187

185-
// TODO: [MG] - consider running the batch in transaction
186-
// if (sqlite)
187-
// sqlite->Execute("BEGIN");
188-
189-
size_t totalSaved = m_offlineStorageDisk->StoreRecords(records);
190-
191-
// TODO: [MG] - consider running the batch in transaction
192-
// if (sqlite)
193-
// sqlite->Execute("END");
188+
// Persist one record at a time so we know exactly which succeeded. A
189+
// batched StoreRecords() only returns a count, so on a partial failure
190+
// we could not tell which records to re-queue, and re-storing
191+
// already-saved records would duplicate them (the events table has no
192+
// unique record_id constraint).
193+
size_t totalSaved = 0;
194+
size_t totalFailed = 0;
195+
for (auto& record : records)
196+
{
197+
if (m_offlineStorageDisk->StoreRecord(record))
198+
{
199+
++totalSaved;
200+
}
201+
else
202+
{
203+
// Return the record to the in-memory queue for retry on a
204+
// subsequent flush instead of dropping it.
205+
++totalFailed;
206+
m_offlineStorageMemory->StoreRecord(record);
207+
}
208+
}
194209

195-
// Delete records from reserved on flush
196-
HttpHeaders dummy;
197-
bool fromMemory = true;
198-
m_offlineStorageMemory->DeleteRecords(ids, dummy, fromMemory);
210+
if (totalFailed > 0)
211+
{
212+
LOG_WARN("Flush: %zu of %zu records failed to persist to disk; returned to the queue for retry",
213+
totalFailed, records.size());
214+
}
199215

200216
// Notify event listener about the records cached
201217
OnStorageRecordsSaved(totalSaved);

tests/unittests/OfflineStorageTests.cpp

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22

33
#include "common/Common.hpp"
44
#include "common/MockIOfflineStorage.hpp"
5+
#include "common/MockIOfflineStorageObserver.hpp"
6+
#include "common/MockIRuntimeConfig.hpp"
7+
#include "offline/OfflineStorageHandler.hpp"
58
#include "offline/StorageObserver.hpp"
9+
#include "NullObjects.hpp"
10+
11+
#include <cstdio>
12+
#include <sstream>
613

714
using namespace testing;
815
using namespace MAT;
@@ -162,3 +169,91 @@ TEST_F(OfflineStorageTests, ReleaseRecordsIsForwarded)
162169
.WillOnce(Return());
163170
EXPECT_THAT(offlineStorage.releaseRecordsIncRetryCount(ctx), true);
164171
}
172+
173+
namespace
174+
{
175+
// Remove a SQLite db file along with its WAL-mode companion files
176+
// (-wal/-shm/-journal), which would otherwise accumulate in the temp dir.
177+
void RemoveDbFiles(const std::string& path)
178+
{
179+
std::remove(path.c_str());
180+
std::remove((path + "-wal").c_str());
181+
std::remove((path + "-shm").c_str());
182+
std::remove((path + "-journal").c_str());
183+
}
184+
185+
// No-op dispatcher that owns queued tasks and frees them, so flushes only
186+
// run when invoked directly and scheduled tasks (if any) are not leaked.
187+
class NoopTaskDispatcher : public ITaskDispatcher
188+
{
189+
public:
190+
void Join() override { clear(); }
191+
void Queue(Task* task) override { m_tasks.push_back(task); }
192+
bool Cancel(Task* task, uint64_t waitTime = 0) override
193+
{
194+
UNREFERENCED_PARAMETER(waitTime);
195+
auto it = std::find(m_tasks.begin(), m_tasks.end(), task);
196+
if (it != m_tasks.end())
197+
{
198+
delete *it;
199+
m_tasks.erase(it);
200+
}
201+
return true;
202+
}
203+
~NoopTaskDispatcher() override { clear(); }
204+
205+
private:
206+
void clear()
207+
{
208+
for (auto* t : m_tasks)
209+
delete t;
210+
m_tasks.clear();
211+
}
212+
std::vector<Task*> m_tasks;
213+
};
214+
}
215+
216+
// Regression test: when records pulled from the in-memory queue fail to persist
217+
// to disk during Flush(), they must be returned to the queue rather than lost.
218+
TEST(OfflineStorageHandlerFlushTests, FailedDiskWriteDuringFlushReturnsRecordsToMemory)
219+
{
220+
NullLogManager logManager;
221+
NiceMock<MockIRuntimeConfig> config;
222+
NoopTaskDispatcher dispatcher;
223+
NiceMock<MockIOfflineStorageObserver> observer;
224+
225+
ON_CALL(config, GetOfflineStorageMaximumSizeBytes()).WillByDefault(Return(32 * 4096));
226+
ON_CALL(config, GetMaximumRetryCount()).WillByDefault(Return(5));
227+
228+
std::ostringstream dbPath;
229+
dbPath << GetTempDirectory() << "FlushReserveTest-" << PAL::getUtcSystemTimeMs() << ".db";
230+
RemoveDbFiles(dbPath.str());
231+
config[CFG_STR_CACHE_FILE_PATH] = dbPath.str();
232+
config[CFG_INT_RAM_QUEUE_SIZE] = 1024 * 1024; // enable the in-memory queue
233+
234+
OfflineStorageHandler handler(logManager, config, dispatcher);
235+
handler.Initialize(observer);
236+
237+
// A timestamp <= 0 is accepted by the in-memory queue but rejected by the
238+
// SQLite disk store's input validation, so its StoreRecord() returns false.
239+
// This drives the same Flush() failure-handling path as a disk write failure
240+
// (a failed record must be returned to memory, not dropped).
241+
const size_t kCount = 5;
242+
for (size_t i = 0; i < kCount; i++)
243+
{
244+
StorageRecord r("flush-id-" + std::to_string(i), "tenant-token",
245+
EventLatency_Normal, EventPersistence_Normal, /*timestamp*/ 0,
246+
std::vector<uint8_t>{ 'x' });
247+
handler.StoreRecord(r);
248+
}
249+
EXPECT_EQ(handler.GetRecordCount(), kCount);
250+
251+
handler.Flush();
252+
253+
// The disk rejected every record; with the fix they are returned to the
254+
// in-memory queue rather than silently dropped.
255+
EXPECT_EQ(handler.GetRecordCount(), kCount);
256+
257+
handler.Shutdown();
258+
RemoveDbFiles(dbPath.str());
259+
}

0 commit comments

Comments
 (0)