|
@@ -872,14 +872,6 @@ bool MemoryStore::unloadDocument(const std::string& collection, const std::strin
|
|
|
return true;
|
|
return true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-bool MemoryStore::remirrorDocument(const std::string& collection, const std::string& id) {
|
|
|
|
|
- // v2.11.0 T12 review (I1) — see the header comment for why this exists.
|
|
|
|
|
- // Delegates to the batched form so there is exactly ONE implementation
|
|
|
|
|
- // of the resolve / skip / catch-per-row rules; a chunk of one is just a
|
|
|
|
|
- // single-document transaction, which is what this used to do anyway.
|
|
|
|
|
- return remirrorDocuments({{collection, id}}, 1).remirrored == 1;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
MemoryStore::RemirrorBatchResult MemoryStore::remirrorDocuments(
|
|
MemoryStore::RemirrorBatchResult MemoryStore::remirrorDocuments(
|
|
|
const std::vector<std::pair<std::string, std::string>>& docs,
|
|
const std::vector<std::pair<std::string, std::string>>& docs,
|
|
|
size_t chunkSize) {
|
|
size_t chunkSize) {
|
|
@@ -914,87 +906,114 @@ MemoryStore::RemirrorBatchResult MemoryStore::remirrorDocuments(
|
|
|
++result.failed;
|
|
++result.failed;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // ---- Phase 1: resolve every row, grouped by project. -----------------
|
|
|
|
|
- // Parsing happens HERE, one row at a time, precisely so a malformed
|
|
|
|
|
- // collection key cannot throw from inside a transaction other rows share.
|
|
|
|
|
- std::map<std::string, std::vector<Row>> byProject;
|
|
|
|
|
- for (const auto& [collection, id] : docs) {
|
|
|
|
|
- std::string project;
|
|
|
|
|
- std::string bare;
|
|
|
|
|
- try {
|
|
|
|
|
- auto pc = smartbotic::database::parseProjectCollection(collection);
|
|
|
|
|
- project = std::move(pc.project);
|
|
|
|
|
- bare = std::move(pc.collection);
|
|
|
|
|
- } catch (const std::exception& e) {
|
|
|
|
|
- noteFailure(collection, id, e.what());
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- // System collections are never mirrored (same rule as
|
|
|
|
|
- // applyDualWriteMirror) — skipped, not a failure.
|
|
|
|
|
- if (bare.empty() || bare[0] == '_') continue;
|
|
|
|
|
-
|
|
|
|
|
- const CollectionData* coll = getCollection(collection);
|
|
|
|
|
- if (!coll) continue;
|
|
|
|
|
- std::optional<Document> doc;
|
|
|
|
|
- {
|
|
|
|
|
- std::shared_lock<std::shared_mutex> lock(coll->mutex);
|
|
|
|
|
- auto it = coll->documents.find(id);
|
|
|
|
|
- if (it == coll->documents.end()) continue; // nothing to mirror
|
|
|
|
|
- doc = it->second;
|
|
|
|
|
- }
|
|
|
|
|
- byProject[project].push_back(Row{collection, std::move(bare), id, std::move(*doc)});
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ---- Phase 2: commit in chunks, per project. -------------------------
|
|
|
|
|
- // Rows that fail their own single-row transaction are deferred for one
|
|
|
|
|
- // final retry pass (phase 3).
|
|
|
|
|
struct Deferred {
|
|
struct Deferred {
|
|
|
smartbotic::db::storage::DocumentStore* ds;
|
|
smartbotic::db::storage::DocumentStore* ds;
|
|
|
Row row;
|
|
Row row;
|
|
|
};
|
|
};
|
|
|
std::vector<Deferred> deferred;
|
|
std::vector<Deferred> deferred;
|
|
|
|
|
|
|
|
- for (auto& [project, rows] : byProject) {
|
|
|
|
|
- auto* ds = docStoreResolver_(project);
|
|
|
|
|
- if (!ds) continue;
|
|
|
|
|
|
|
+ // ⚠ CATCH-ALL, deliberately (round 5): every individual step below is
|
|
|
|
|
+ // already guarded, but "never throws" must be a property of the
|
|
|
|
|
+ // function, not a claim about the steps I remembered. Copying a Document
|
|
|
|
|
+ // can throw std::bad_alloc, and getCollection()/shared_lock can throw
|
|
|
|
|
+ // std::system_error - neither is a per-row fault this pass can attribute,
|
|
|
|
|
+ // and either escaping into the unguarded recover() call reproduces
|
|
|
|
|
+ // exactly the boot loop this round's finding 1 was about.
|
|
|
|
|
+ try {
|
|
|
|
|
+
|
|
|
|
|
+ // ---- Phase 1+2, STREAMED: resolve one window, commit it, drop it. ----
|
|
|
|
|
+ // v2.11.0 T12 round-5 — this used to resolve EVERY id in `docs` into a
|
|
|
|
|
+ // per-project map before committing anything, with `Row` holding its
|
|
|
|
|
+ // Document BY VALUE. That traded a slow boot for an OOM-killed one: the
|
|
|
|
|
+ // input can be an install's entire history under
|
|
|
|
|
+ // --recovery-mode=wal_only, and this repo has measured document shapes
|
|
|
|
|
+ // at ~2.5-2.9 MB each (anime_images), so 10k distinct ids would have
|
|
|
|
|
+ // been tens of GB of document clones resident at once, on the boot path,
|
|
|
|
|
+ // before READY. A slow boot completes; a killed one does not.
|
|
|
|
|
+ //
|
|
|
|
|
+ // Now at most `chunkSize` documents (plus `deferred`, which is small by
|
|
|
|
|
+ // construction - only rows that failed their own transaction) are ever
|
|
|
|
|
+ // resident. Each window is resolved, committed, and released before the
|
|
|
|
|
+ // next window is resolved.
|
|
|
|
|
+ //
|
|
|
|
|
+ // Parsing happens per row inside the window, NOT inside the transaction,
|
|
|
|
|
+ // precisely so a malformed collection key cannot throw from a
|
|
|
|
|
+ // transaction other rows share. A window may span several projects (one
|
|
|
|
|
+ // env each, so a transaction cannot span them): it is grouped by project
|
|
|
|
|
+ // and committed once per project present in that window. Worst case -
|
|
|
|
|
+ // every row in a different project - degrades to a transaction per row,
|
|
|
|
|
+ // which is exactly the pre-batching cost and never worse.
|
|
|
|
|
+ for (size_t windowStart = 0; windowStart < docs.size(); windowStart += chunkSize) {
|
|
|
|
|
+ const size_t windowEnd = std::min(windowStart + chunkSize, docs.size());
|
|
|
|
|
+
|
|
|
|
|
+ std::map<std::string, std::vector<Row>> byProject;
|
|
|
|
|
+ for (size_t i = windowStart; i < windowEnd; ++i) {
|
|
|
|
|
+ const std::string& collection = docs[i].first;
|
|
|
|
|
+ const std::string& id = docs[i].second;
|
|
|
|
|
+ std::string project;
|
|
|
|
|
+ std::string bare;
|
|
|
|
|
+ try {
|
|
|
|
|
+ auto pc = smartbotic::database::parseProjectCollection(collection);
|
|
|
|
|
+ project = std::move(pc.project);
|
|
|
|
|
+ bare = std::move(pc.collection);
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ noteFailure(collection, id, e.what());
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // System collections are never mirrored (same rule as
|
|
|
|
|
+ // applyDualWriteMirror) — skipped, not a failure.
|
|
|
|
|
+ if (bare.empty() || bare[0] == '_') continue;
|
|
|
|
|
+
|
|
|
|
|
+ const CollectionData* coll = getCollection(collection);
|
|
|
|
|
+ if (!coll) continue;
|
|
|
|
|
+ std::optional<Document> doc;
|
|
|
|
|
+ {
|
|
|
|
|
+ std::shared_lock<std::shared_mutex> lock(coll->mutex);
|
|
|
|
|
+ auto it = coll->documents.find(id);
|
|
|
|
|
+ if (it == coll->documents.end()) continue; // nothing to mirror
|
|
|
|
|
+ doc = it->second;
|
|
|
|
|
+ }
|
|
|
|
|
+ byProject[project].push_back(Row{collection, std::move(bare), id, std::move(*doc)});
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- for (size_t start = 0; start < rows.size(); start += chunkSize) {
|
|
|
|
|
- const size_t end = std::min(start + chunkSize, rows.size());
|
|
|
|
|
|
|
+ for (auto& [project, rows] : byProject) {
|
|
|
|
|
+ auto* ds = docStoreResolver_(project);
|
|
|
|
|
+ if (!ds) continue;
|
|
|
|
|
|
|
|
- bool chunkCommitted = false;
|
|
|
|
|
- if (end - start > 1) {
|
|
|
|
|
|
|
+ bool committed = false;
|
|
|
|
|
+ if (rows.size() > 1) {
|
|
|
// put_batch() is all-or-nothing (one WriteTxn, one commit,
|
|
// put_batch() is all-or-nothing (one WriteTxn, one commit,
|
|
|
// handles cached only after that commit succeeds - the Task
|
|
// handles cached only after that commit succeeds - the Task
|
|
|
// 10 invariant lives inside it, not here).
|
|
// 10 invariant lives inside it, not here).
|
|
|
std::vector<smartbotic::db::storage::DocumentStore::BatchPutItem> items;
|
|
std::vector<smartbotic::db::storage::DocumentStore::BatchPutItem> items;
|
|
|
- items.reserve(end - start);
|
|
|
|
|
- for (size_t i = start; i < end; ++i) {
|
|
|
|
|
- items.push_back({rows[i].bare, rows[i].id, &rows[i].doc});
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ items.reserve(rows.size());
|
|
|
|
|
+ for (auto& r : rows) items.push_back({r.bare, r.id, &r.doc});
|
|
|
try {
|
|
try {
|
|
|
ds->put_batch(items);
|
|
ds->put_batch(items);
|
|
|
- chunkCommitted = true;
|
|
|
|
|
- result.remirrored += end - start;
|
|
|
|
|
|
|
+ committed = true;
|
|
|
|
|
+ result.remirrored += rows.size();
|
|
|
} catch (const std::exception& e) {
|
|
} catch (const std::exception& e) {
|
|
|
spdlog::warn("post-replay re-mirror: chunk of {} document(s) in "
|
|
spdlog::warn("post-replay re-mirror: chunk of {} document(s) in "
|
|
|
"project '{}' aborted ({}); retrying it row by row "
|
|
"project '{}' aborted ({}); retrying it row by row "
|
|
|
"so one bad row cannot leave the rest stale",
|
|
"so one bad row cannot leave the rest stale",
|
|
|
- end - start, project, e.what());
|
|
|
|
|
|
|
+ rows.size(), project, e.what());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (chunkCommitted) continue;
|
|
|
|
|
|
|
+ if (committed) continue;
|
|
|
|
|
|
|
|
// Row by row: either the chunk aborted, or it was a chunk of one.
|
|
// Row by row: either the chunk aborted, or it was a chunk of one.
|
|
|
- for (size_t i = start; i < end; ++i) {
|
|
|
|
|
|
|
+ for (auto& r : rows) {
|
|
|
try {
|
|
try {
|
|
|
- ds->put(rows[i].bare, rows[i].id, rows[i].doc);
|
|
|
|
|
|
|
+ ds->put(r.bare, r.id, r.doc);
|
|
|
++result.remirrored;
|
|
++result.remirrored;
|
|
|
} catch (const std::exception&) {
|
|
} catch (const std::exception&) {
|
|
|
- deferred.push_back(Deferred{ds, rows[i]});
|
|
|
|
|
|
|
+ deferred.push_back(Deferred{ds, std::move(r)});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ // byProject (and every Document in it) is released here, before the
|
|
|
|
|
+ // next window is resolved. That release is the whole point.
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- Phase 3: one bounded retry for rows that failed alone. ---------
|
|
// ---- Phase 3: one bounded retry for rows that failed alone. ---------
|
|
@@ -1002,6 +1021,11 @@ MemoryStore::RemirrorBatchResult MemoryStore::remirrorDocuments(
|
|
|
// value that used to live on row A conflicts against A's stale posting
|
|
// value that used to live on row A conflicts against A's stale posting
|
|
|
// until A itself has been re-mirrored. Exactly one extra attempt - no
|
|
// until A itself has been re-mirrored. Exactly one extra attempt - no
|
|
|
// loop, so a genuinely unresolvable conflict cannot spin the boot path.
|
|
// loop, so a genuinely unresolvable conflict cannot spin the boot path.
|
|
|
|
|
+ //
|
|
|
|
|
+ // ⚠ NOT COVERED BY ANY TEST: no test in the suite induces a
|
|
|
|
|
+ // UniqueViolation from this pass, so replacing this retry with a bare
|
|
|
|
|
+ // noteFailure() would still pass everything. Recorded in the task-12
|
|
|
|
|
+ // report rather than papered over.
|
|
|
for (const auto& d : deferred) {
|
|
for (const auto& d : deferred) {
|
|
|
try {
|
|
try {
|
|
|
d.ds->put(d.row.bare, d.row.id, d.row.doc);
|
|
d.ds->put(d.row.bare, d.row.id, d.row.doc);
|
|
@@ -1011,6 +1035,19 @@ MemoryStore::RemirrorBatchResult MemoryStore::remirrorDocuments(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ spdlog::error("post-replay re-mirror pass aborted early: {} - the rows it had "
|
|
|
|
|
+ "not reached yet stay stale in LMDB; recovery continues", e.what());
|
|
|
|
|
+ if (mirrorDriftCount_) mirrorDriftCount_->fetch_add(1, std::memory_order_relaxed);
|
|
|
|
|
+ ++result.failed;
|
|
|
|
|
+ } catch (...) {
|
|
|
|
|
+ spdlog::error("post-replay re-mirror pass aborted early (unknown exception) - "
|
|
|
|
|
+ "the rows it had not reached yet stay stale in LMDB; recovery "
|
|
|
|
|
+ "continues");
|
|
|
|
|
+ if (mirrorDriftCount_) mirrorDriftCount_->fetch_add(1, std::memory_order_relaxed);
|
|
|
|
|
+ ++result.failed;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
|