touch: segmented-store loader + verify-then-delete migration (phase 3)

loadHistoryFromStorage now prefers segments over the split v6 file over
the legacy combined file. The segment loader reads oldest-first into the
ring, quarantines corrupt segments INDIVIDUALLY (one bad record used to
discard the entire history), truncates crash-ragged tails at the record
boundary, keeps the newest <= cap records when the disk set outgrows
this boot's ring (three-reversal rotation restores the linear-ring
invariant), reconciles the segment table (fully-dropped segments deleted,
the boundary segment marked compact-dirty), restores real seqs and seeds
the generator, and sweeps orphaned segment tmps.

When an old format supplied the ring, migrateRingToSegments() converts
it: segments written from the ring, every one read back and verified
(count + seq bounds) before the old msgs file is deleted; any failure
rolls the whole segment set back so the intact old file stays
authoritative next boot, and s_seg_store_ready stays false so the flush
scheduler (phase 4) retries the migration instead of writing segments
that would shadow it.

The companion message counter rides the threads-file header now (the
segments don't store it); the mesh re-asserts it per message anyway.

Builds all five envs (loader wired; writers wire in phase 4).

Per .notes/segmented-store-plan.md phase 3.

Signed-off-by: Dan Vybiral <dan.vybiral@greencode.cz>
This commit is contained in:
Dan Vybiral committed 2026-07-26 22:48:05 +02:00
1 parent 3d487aefcf
commit fb0346e249
2 files changed
+237 -16

No files matched your search

+235 -16
View File
@@ -4633,6 +4633,36 @@ static void chatSaveFailText(char* out, size_t cap) {
}
static bool uiDataFsIsSdCard(); // fwd (storage code below) — About page shows the resolved backend
static File uiDataOpen(const char* name, const char* mode); // fwd — About page reads the msgs file size
// ---- Segmented store: runtime segment table (loop-task-owned) --------------
// Oldest-first view of the on-disk segment set. Rebuilt by the boot loader,
// consulted and updated by the flush scheduler. The hist_flush worker never
// touches it — it only executes fully-described jobs and reports ok/fail.
struct UiSegInfo {
uint32_t first_seq; // filename key + first record's seq
uint32_t last_seq; // newest seq physically in the FILE
uint32_t bytes; // file size (header + records) — arithmetic, never stat()
uint16_t disk_recs; // records physically in the file
uint16_t live_recs; // of those, how many are still live in the RAM ring
uint8_t compact_dirty; // RAM tombstones/drops within this segment -> rewrite due
};
static UiSegInfo s_seg[k_ui_seg_max];
static int s_seg_count = 0;
static volatile uint32_t s_seg_total_bytes = 0; // writer-maintained; About page reads it
static volatile bool s_seg_resync = false; // card swapped/remounted: rewrite everything from RAM
// False until the boot loader read segments OR the one-time migration landed.
// While false the flush scheduler must not append segments (they would shadow
// the still-authoritative old-format file on the next boot) — it retries the
// migration instead.
static bool s_seg_store_ready = false;
// fwd decls — the ops live with the storage code far below.
static void uiDataEnsureDirs();
static int uiSegScan(uint32_t* out_first_seqs, int max_out, bool sweep_tmps);
static bool uiSegOpenValidated(uint32_t first_seq, File& f, uint16_t* rec_size_out);
static bool uiSegReadRec(File& f, uint16_t disk_sz, UITask::UIMessage* out, uint32_t* seq_out);
static bool uiSegCompactWrite(uint32_t first_seq, const UITask::UIMessage* recs, int n, bool sync_writer);
static bool uiSegAppendRecords(uint32_t first_seq, bool create, const UITask::UIMessage* recs, int n);
static void uiSegRemoveFile(uint32_t first_seq);
static volatile bool s_sdinfo_done = false; // worker -> UI: a result exists
static volatile bool s_sdinfo_ok = false; // card present + sizes valid
static uint64_t s_sdinfo_tot = 0;
@@ -41309,6 +41339,10 @@ bool UITask::loadThreadsFromStorage() {
const size_t disk_sz =
(hdr.version >= 5 && hdr.thread_rec_size) ? hdr.thread_rec_size : sizeof(UiHistoryThread);
if (disk_sz == 0 || disk_sz > 4096) { f.close(); uiDataRemove(k_ui_threads_path); return false; }
// Restore the companion message counter carried here for the segmented msgs
// store (older writers left it 0; the msgs-file loaders overwrite it anyway
// when they run, and the mesh re-asserts it with every message).
if (hdr.msgcount) _msgcount = static_cast<int>(hdr.msgcount);
UiHistoryThread t{};
for (int i = 0; i < MAX_UI_THREADS; ++i) {
@@ -41414,6 +41448,175 @@ bool UITask::loadMsgsFromStorage() {
#endif
}
// Segmented-store loader (generation 3). Reads every discovered segment
// oldest-first into the ring, quarantining corrupt segments INDIVIDUALLY (the
// single-file store threw the entire history away on one bad record), keeping
// the newest <= cap records when the on-disk set is larger than this boot's
// ring (card written under the deep cap, loaded under the small one), and
// leaving the ring LINEAR (slot 0 = oldest, head = n) — the same post-load
// invariant every ring consumer relies on. Returns false when no segment
// files exist at all, so the caller can fall back to the older formats.
bool UITask::loadMsgsFromSegments() {
#if defined(ESP32)
uint32_t seqs[k_ui_seg_max];
const int nseg = uiSegScan(seqs, k_ui_seg_max, true /*sweep orphaned tmps*/);
s_seg_count = 0;
s_seg_total_bytes = 0;
if (nseg <= 0) return false;
WdtHeavyGuard _wg; // ~20 segment reads on a cold card can take a while
int w = 0; // running chronological index; ring slot = w % cap
uint32_t max_seq = 0;
for (int si = 0; si < nseg; ++si) {
File f;
uint16_t rec_sz = 0;
if (!uiSegOpenValidated(seqs[si], f, &rec_sz)) {
uiSegRemoveFile(seqs[si]); // quarantine THIS segment only
continue;
}
UiSegInfo info{};
info.first_seq = seqs[si];
UIMessage rec;
uint32_t seq = 0;
uint16_t nrec = 0;
while (uiSegReadRec(f, rec_sz, &rec, &seq)) { // stops at EOF or a crash-ragged tail
if (rec.seq == 0) rec.seq = max_seq + 1; // defensive backfill (pre-seq record)
if (rec.seq > max_seq) max_seq = rec.seq;
info.last_seq = rec.seq;
_ui_msgs[w % _ui_msg_cap] = rec;
++w;
++nrec;
}
f.close();
info.disk_recs = nrec;
info.live_recs = nrec;
info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)nrec * (uint32_t)rec_sz;
if (nrec == 0) { uiSegRemoveFile(seqs[si]); continue; } // header-only husk — reclaim
if (s_seg_count < k_ui_seg_max) s_seg[s_seg_count++] = info;
}
const int total = w;
const int n_keep = total > _ui_msg_cap ? _ui_msg_cap : total;
if (total > _ui_msg_cap) {
// The ring wrapped while loading: the oldest KEPT record sits at slot
// total % cap. Rotate left by that amount so the ring is linear again.
// Three-reversal rotation: O(cap) record swaps, no large temp buffer.
const int cap = _ui_msg_cap;
const int k = total % cap;
if (k > 0) {
auto rev = [this](int a, int b) {
while (a < b) {
UIMessage t = _ui_msgs[a];
_ui_msgs[a] = _ui_msgs[b];
_ui_msgs[b] = t;
++a; --b;
}
};
rev(0, k - 1);
rev(k, cap - 1);
rev(0, cap - 1);
}
}
// Reconcile the table with any dropped-oldest records: fully-dropped
// segments violate the disk == ring invariant and are deleted outright; the
// boundary segment keeps its file but is marked compact-dirty so the next
// compaction (rewritten from the RAM ring) trims it to the kept records.
const uint32_t min_kept_seq = n_keep > 0 ? _ui_msgs[0].seq : 0;
int out = 0;
uint32_t total_bytes = 0;
for (int i = 0; i < s_seg_count; ++i) {
if (n_keep == 0 || s_seg[i].last_seq < min_kept_seq) {
uiSegRemoveFile(s_seg[i].first_seq);
continue;
}
if (s_seg[i].first_seq < min_kept_seq) s_seg[i].compact_dirty = 1;
total_bytes += s_seg[i].bytes;
s_seg[out++] = s_seg[i];
}
s_seg_count = out;
s_seg_total_bytes = total_bytes;
_ui_msg_count = n_keep;
_ui_msg_head = n_keep % _ui_msg_cap;
_ui_seq_next = max_seq + 1;
// _msgcount isn't stored in segments; the threads-file header carries it and
// the mesh layer re-asserts it with every message. Keep it monotonic vs seq.
if ((int)max_seq > _msgcount) _msgcount = (int)max_seq;
s_seg_store_ready = true;
return true;
#else
return false;
#endif
}
// One-time migration: the ring was just loaded from an OLD format (split v6
// single file or the legacy combined file) — write it out as segments, read
// every segment back and verify counts + seq bounds, and only then delete the
// old msgs file. Any failure rolls the segment set back completely so the
// intact old file stays authoritative on the next boot (a partial segment set
// would otherwise shadow it in the loader's precedence order).
bool UITask::migrateRingToSegments() {
#if defined(ESP32)
uiDataEnsureDirs();
s_seg_count = 0;
s_seg_total_bytes = 0;
const int n = _ui_msg_count; // ring is linear post-load: slots 0..n-1, oldest first
bool ok = true;
for (int base = 0; base < n && ok; base += k_ui_seg_records) {
int cnt = n - base;
if (cnt > k_ui_seg_records) cnt = k_ui_seg_records;
const uint32_t fseq = _ui_msgs[base].seq;
ok = uiSegCompactWrite(fseq, &_ui_msgs[base], cnt, true /*sync-writer tmp*/);
if (ok && s_seg_count < k_ui_seg_max) {
UiSegInfo info{};
info.first_seq = fseq;
info.last_seq = _ui_msgs[base + cnt - 1].seq;
info.disk_recs = info.live_recs = (uint16_t)cnt;
info.bytes = (uint32_t)sizeof(UiSegHeader) + (uint32_t)cnt * (uint32_t)sizeof(UiSegMsg);
s_seg[s_seg_count++] = info;
}
}
if (ok) {
// Read-back verification: every segment must reproduce its exact record
// count and seq bounds before the old file may be deleted.
for (int i = 0; ok && i < s_seg_count; ++i) {
File f;
uint16_t rec_sz = 0;
if (!uiSegOpenValidated(s_seg[i].first_seq, f, &rec_sz)) { ok = false; break; }
UIMessage r;
uint32_t seq = 0, first = 0, last = 0;
uint16_t cnt = 0;
while (uiSegReadRec(f, rec_sz, &r, &seq)) {
if (cnt == 0) first = seq;
last = seq;
++cnt;
}
f.close();
ok = (cnt == s_seg[i].disk_recs &&
first == s_seg[i].first_seq &&
last == s_seg[i].last_seq);
}
}
if (!ok) {
for (int i = 0; i < s_seg_count; ++i) uiSegRemoveFile(s_seg[i].first_seq);
s_seg_count = 0;
s_seg_total_bytes = 0;
return false; // old file untouched; the scheduler retries the migration later
}
uint32_t total_bytes = 0;
for (int i = 0; i < s_seg_count; ++i) total_bytes += s_seg[i].bytes;
s_seg_total_bytes = total_bytes;
uiDataRemove(k_ui_msgs_path);
uiDataRemove(k_ui_msgs_tmp_path);
uiDataRemove(k_ui_msgs_tmp2_path);
s_seg_store_ready = true;
return true;
#else
return false;
#endif
}
bool UITask::loadLegacyHistoryFromStorage() {
#if defined(ESP32)
File f = uiDataOpen(k_ui_history_path, "r");
@@ -41507,18 +41710,28 @@ bool UITask::loadLegacyHistoryFromStorage() {
bool UITask::loadHistoryFromStorage() {
#if defined(ESP32)
// Hygiene: a crash/power-cut mid-flush can orphan a temp file; they're never
// read (the loader only opens the final path), just reclaim the space.
uiDataRemove(k_ui_msgs_tmp_path);
uiDataRemove(k_ui_msgs_tmp2_path);
const bool have_threads = loadThreadsFromStorage();
const bool have_msgs = loadMsgsFromStorage();
if (!have_threads && !have_msgs) {
// Neither split file found — try the legacy combined format for migration.
if (!loadLegacyHistoryFromStorage()) return false;
// Schedule writes of the new split files; old file is left in place.
markThreadsDirty(500);
markMsgsDirty(500);
// Format precedence: segments (generation 3) > split v6 single file >
// legacy combined file. The older loaders are retained as migration
// readers; whenever one of them supplies the ring, migrateRingToSegments()
// below converts it (verify-then-delete, rollback on any failure).
const bool from_segments = loadMsgsFromSegments();
bool have_msgs = from_segments;
if (!from_segments) {
// Single-file-era hygiene: a crash mid-flush can orphan a temp file;
// they're never read, just reclaim the space. (Segment tmps are swept by
// the scan inside loadMsgsFromSegments.)
uiDataRemove(k_ui_msgs_tmp_path);
uiDataRemove(k_ui_msgs_tmp2_path);
have_msgs = loadMsgsFromStorage();
if (!have_threads && !have_msgs) {
// Neither split file found — try the legacy combined format.
if (!loadLegacyHistoryFromStorage()) return false;
// Schedule the split threads-file write; messages land in segments via
// the migration below (the legacy combined file is left in place, as
// the split migration always did).
markThreadsDirty(500);
}
}
// Clear persisted unread counts for threads whose messages are no longer in
// the ring. The display ring is a fixed-size cache independent of the unread
@@ -41533,16 +41746,19 @@ bool UITask::loadHistoryFromStorage() {
_ui_threads[i].has_mention = false;
}
}
// Backfill per-record sequence numbers for records loaded from the pre-segment
// formats (the single-file/legacy records carry no seq on disk): stamp them in
// chronological order and seed the generator past them. The segmented loader
// will restore REAL seqs from disk and skip this backfill.
{
if (!from_segments) {
// Backfill per-record sequence numbers for records loaded from the
// pre-segment formats (their records carry no seq on disk): stamp them in
// chronological order, seed the generator past them, then convert the
// ring to segments. A failed migration leaves the old file authoritative
// and s_seg_store_ready false — the flush scheduler retries it before it
// will write any segment.
for (int i = 0; i < _ui_msg_count; ++i) {
const int slot = (_ui_msg_head - _ui_msg_count + i + _ui_msg_cap) % _ui_msg_cap;
_ui_msgs[slot].seq = (uint32_t)(i + 1);
}
_ui_seq_next = (uint32_t)_ui_msg_count + 1;
migrateRingToSegments();
}
return true;
#else
@@ -41562,6 +41778,9 @@ bool UITask::saveThreadsToStorage() {
hdr.thread_rec_size = static_cast<uint16_t>(sizeof(UiHistoryThread));
hdr.active_thread_idx = static_cast<int16_t>(_active_thread_idx);
hdr.active_thread_is_channel = _active_thread_is_channel ? 1u : 0u;
// The segmented msgs store doesn't carry the companion message counter —
// this (frequently-rewritten, small) file does instead.
hdr.msgcount = static_cast<uint32_t>(_msgcount);
if (f.write(reinterpret_cast<const uint8_t*>(&hdr), sizeof(hdr)) != sizeof(hdr)) {
f.close(); return false;
}
+2
View File
@@ -243,6 +243,8 @@ private:
bool loadThreadsFromStorage();
bool loadMsgsFromStorage();
bool loadLegacyHistoryFromStorage();
bool loadMsgsFromSegments(); // segmented store (generation 3) loader
bool migrateRingToSegments(); // one-time old-format -> segments migration (verify-then-delete)
bool saveThreadsToStorage();
bool saveMsgsToStorage();