瀏覽代碼

feat: auto-migrate v1.x data dirs on boot

Adds --migrate-from-v1 (force) and --no-auto-migrate (refuse) CLI flags
to smartbotic-database, plus an auto-detect path that runs the one-shot
migration before service.initialize() if v1.x markers are present and
no schema_version=2 marker exists in the v2.0 env's _meta sub-db.

The decision logic is factored into should_run_migration() in
storage/migrate_v1_to_v2.{hpp,cpp} as a pure function over data-dir
contents + flags + env state, so the auto-detect tests don't depend on
main.cpp's signal/lifecycle path.

Decision rules:
  - migration_complete(env) → SkipAlreadyMigrated
  - opts.force              → RunMigration (operator escape hatch)
  - no v1 snapshots         → SkipNoV1Data (treat as fresh install)
  - opts.disabled           → BlockedByFlag (exit 12)
  - otherwise               → RunMigration

Boot-path behaviour (main.cpp):
  - Opens v2.0 env at <dataDir>/env/
  - Logs a loud banner when migration runs
  - Streams progress through sd_notify STATUS + EXTEND_TIMEOUT_USEC so
    systemd's start watchdog doesn't fire mid-migration on large datasets
  - Exit codes: 12 = migration blocked by --no-auto-migrate;
                13 = migration failure

Tests added (test_migrate_v1_to_v2):
  - no v1 dir present → SkipNoV1Data
  - v1 dir present → RunMigration
  - v1 dir + --no-auto-migrate → BlockedByFlag (with operator-facing msg)
  - --migrate-from-v1 forces RunMigration even without v1 markers
  - already-migrated env → SkipAlreadyMigrated

Phase C Stage 3 Task 3.2.
fszontagh 2 月之前
父節點
當前提交
15893b19b6
共有 4 個文件被更改,包括 336 次插入1 次删除
  1. 136 1
      service/src/main.cpp
  2. 51 0
      service/src/storage/migrate_v1_to_v2.cpp
  3. 39 0
      service/src/storage/migrate_v1_to_v2.hpp
  4. 110 0
      tests/test_migrate_v1_to_v2.cpp

+ 136 - 1
service/src/main.cpp

@@ -1,6 +1,9 @@
 #include "database_service.hpp"
 #include "config/config_loader.hpp"
 #include "persistence/persistence_manager.hpp"
+#include "storage/document_store_lmdb.hpp"
+#include "storage/lmdb_env.hpp"
+#include "storage/migrate_v1_to_v2.hpp"
 
 #include <spdlog/spdlog.h>
 #include <spdlog/sinks/stdout_color_sinks.h>
@@ -95,6 +98,10 @@ void printUsage(const char* programName) {
               << "  --recovery-mode=MODE       Override recovery mode for this boot\n"
               << "                             Values: normal, snapshot_fallback, wal_only,\n"
               << "                                     best_effort, force_empty\n"
+              << "  --migrate-from-v1          Force one-shot v1.x → v2.0 migration even\n"
+              << "                             if heuristics don't fire (operator escape hatch)\n"
+              << "  --no-auto-migrate          Refuse to auto-migrate. If v1.x data is detected\n"
+              << "                             at the data directory, exit non-zero.\n"
               << "\n"
               << "Environment variables:\n"
               << "  DATABASE_NODE_ID     Override node ID from config\n"
@@ -140,6 +147,9 @@ struct CliOverrides {
     bool readOnly = false;
     bool forceReadwrite = false;
     std::optional<smartbotic::database::RecoveryMode> recoveryMode;
+    // v2.0 storage Phase C Stage 3 — one-shot v1.x → v2.0 migration toggles.
+    bool migrateFromV1 = false;     // --migrate-from-v1 (force, escape hatch)
+    bool noAutoMigrate = false;     // --no-auto-migrate (refuse auto-detect)
 };
 
 CliOverrides parseCliOverrides(int argc, char* argv[]) {
@@ -150,6 +160,10 @@ CliOverrides parseCliOverrides(int argc, char* argv[]) {
             ov.readOnly = true;
         } else if (arg == "--force-readwrite") {
             ov.forceReadwrite = true;
+        } else if (arg == "--migrate-from-v1") {
+            ov.migrateFromV1 = true;
+        } else if (arg == "--no-auto-migrate") {
+            ov.noAutoMigrate = true;
         } else if (arg.rfind("--recovery-mode=", 0) == 0) {
             std::string mode = arg.substr(std::string("--recovery-mode=").size());
             try {
@@ -163,6 +177,113 @@ CliOverrides parseCliOverrides(int argc, char* argv[]) {
     return ov;
 }
 
+// Run the v1.x → v2.0 auto-detect-and-migrate phase. Called from main()
+// before service.initialize() so the heavy v1.x recovery sequence doesn't
+// run twice. Returns 0 on success or on a clean "no migration needed"
+// decision; non-zero on a refused migration (operator must intervene).
+//
+// On success, the v2.0 schema_version=2 marker is set in the env's _meta
+// sub-db, so subsequent boots short-circuit on the migration_complete()
+// check inside should_run_migration().
+int runV1MigrationIfNeeded(const std::filesystem::path& dataDir,
+                            bool forceMigrate, bool noAutoMigrate) {
+    using namespace smartbotic::db::storage;
+
+    // v2.0 env lives at <dataDir>/env/ — separate from v1.x's snapshots/
+    // and wal/. Open via the Stage 1 RAII wrapper.
+    LmdbEnvOpts envOpts;
+    envOpts.path = (dataDir / "env").string();
+    envOpts.map_size_bytes = 4ULL << 30;  // 4 GiB initial; LMDB grows lazily.
+    envOpts.max_dbs = 1024;
+
+    std::error_code ec;
+    std::filesystem::create_directories(envOpts.path, ec);
+
+    std::unique_ptr<LmdbEnv> env;
+    try {
+        env = std::make_unique<LmdbEnv>(envOpts);
+    } catch (const std::exception& e) {
+        spdlog::error("Failed to open v2.0 LMDB env at {}: {}", envOpts.path, e.what());
+        // Pre-Stage-4: this isn't a hard failure path yet because the
+        // service still boots from v1.x. Treat as "skip migration, log it"
+        // and let the rest of boot proceed. Once Stage 4 lands, this
+        // becomes a hard exit.
+        return 0;
+    }
+
+    AutoMigrateOptions opts;
+    opts.force = forceMigrate;
+    opts.disabled = noAutoMigrate;
+    AutoMigrateDecision decision =
+        should_run_migration(dataDir.string(), *env, opts);
+
+    switch (decision.action) {
+        case AutoMigrateDecision::Action::SkipNoV1Data:
+            spdlog::info("v2.0 migration: {}", decision.message);
+            return 0;
+        case AutoMigrateDecision::Action::SkipAlreadyMigrated:
+            spdlog::info("v2.0 migration: {}", decision.message);
+            return 0;
+        case AutoMigrateDecision::Action::BlockedByFlag:
+            spdlog::error("v2.0 migration BLOCKED: {}", decision.message);
+            std::cerr << "ERROR: " << decision.message << std::endl;
+            return 12;  // exit code 12 = migration refused by operator
+        case AutoMigrateDecision::Action::RunMigration:
+            break;
+    }
+
+    // Loud banner — operators reading the journal need to know exactly
+    // what's about to happen.
+    spdlog::info("================================================================");
+    spdlog::info("v2.0 storage migration starting");
+    spdlog::info("  Source v1.x data dir: {}", dataDir.string());
+    spdlog::info("  Target v2.0 LMDB env: {}", envOpts.path);
+    spdlog::info("  This is a one-shot operation. The v1.x data dir is");
+    spdlog::info("  read-only and will not be modified. Rollback path:");
+    spdlog::info("  restore /var/backups/smartbotic-database/pre-upgrade-*/.");
+    spdlog::info("================================================================");
+
+#ifdef HAVE_SYSTEMD
+    sd_notify(0, "EXTEND_TIMEOUT_USEC=600000000");
+    sd_notify(0, "STATUS=Migrating from v1.x");
+#endif
+
+    LmdbDocumentStore target(*env);
+
+    auto progressCb = [](const MigrationProgress& p) {
+#ifdef HAVE_SYSTEMD
+        std::string status = "STATUS=Migrating from v1.x: " +
+                             std::to_string(p.docs_migrated) + "/" +
+                             std::to_string(p.docs_total);
+        if (!p.current_collection.empty()) {
+            status += " in '" + p.current_collection + "'";
+        }
+        sd_notify(0, status.c_str());
+        sd_notify(0, "EXTEND_TIMEOUT_USEC=600000000");
+#endif
+        if (p.docs_migrated > 0 && (p.docs_migrated % 5000) == 0) {
+            spdlog::info("v2.0 migration progress: {}/{} docs (collection '{}')",
+                         p.docs_migrated, p.docs_total, p.current_collection);
+        }
+    };
+
+    MigrationResult result =
+        migrate_v1_to_v2(dataDir.string(), target, *env, progressCb);
+
+    if (!result.success) {
+#ifdef HAVE_SYSTEMD
+        sd_notify(0, "STATUS=Migration failed");
+#endif
+        spdlog::error("v2.0 migration FAILED: {}", result.error);
+        std::cerr << "ERROR: v2.0 migration failed:\n  " << result.error << std::endl;
+        return 13;  // exit code 13 = migration failure
+    }
+
+    spdlog::info("v2.0 migration complete: {} docs across {} collections",
+                 result.docs_migrated, result.collections_migrated);
+    return 0;
+}
+
 std::filesystem::path findConfigFile(int argc, char* argv[]) {
     // Check command line arguments
     for (int i = 1; i < argc; ++i) {
@@ -280,7 +401,8 @@ int main(int argc, char* argv[]) {
             config.nodeId = nodeId;
         }
 
-        // Parse CLI overrides (--read-only, --force-readwrite, --recovery-mode=...)
+        // Parse CLI overrides (--read-only, --force-readwrite, --recovery-mode=...,
+        // --migrate-from-v1, --no-auto-migrate)
         auto cli = parseCliOverrides(argc, argv);
 
         // Apply --recovery-mode to persistence config before service construction
@@ -288,6 +410,19 @@ int main(int argc, char* argv[]) {
             config.persistenceConfig.recoveryMode = *cli.recoveryMode;
         }
 
+        // v2.0 storage Phase C Stage 3 — auto-detect-and-migrate before the
+        // service initialises. Runs against <dataDir>/env/ (the v2.0 LMDB
+        // env). On success, sets the schema_version=2 marker so subsequent
+        // boots short-circuit. On a refused migration (--no-auto-migrate +
+        // v1.x data present, or migration failure) we exit non-zero before
+        // the service starts.
+        if (int rc = runV1MigrationIfNeeded(config.dataDirectory,
+                                             cli.migrateFromV1,
+                                             cli.noAutoMigrate);
+            rc != 0) {
+            return rc;
+        }
+
         // Create service
         smartbotic::database::DatabaseService service(config);
         g_service = &service;

+ 51 - 0
service/src/storage/migrate_v1_to_v2.cpp

@@ -263,6 +263,57 @@ bool migration_complete(LmdbEnv& env) {
     }
 }
 
+AutoMigrateDecision should_run_migration(const std::string& v1_data_dir,
+                                          LmdbEnv& env,
+                                          const AutoMigrateOptions& opts) {
+    AutoMigrateDecision d;
+    if (migration_complete(env)) {
+        d.action = AutoMigrateDecision::Action::SkipAlreadyMigrated;
+        d.message = "v2.0 schema marker present — skipping migration";
+        return d;
+    }
+    if (opts.force) {
+        d.action = AutoMigrateDecision::Action::RunMigration;
+        d.message = "operator forced migration via --migrate-from-v1";
+        return d;
+    }
+
+    fs::path snap_dir = fs::path(v1_data_dir) / "snapshots";
+    bool has_v1 = false;
+    std::error_code ec;
+    if (fs::exists(snap_dir, ec) && fs::is_directory(snap_dir, ec)) {
+        for (auto it = fs::directory_iterator(snap_dir, ec);
+             !ec && it != fs::directory_iterator(); ++it) {
+            const auto& p = it->path();
+            // Snapshot files are "snapshot-*.dat" — anything matching that
+            // shape is a v1.x marker. A bare empty dir is treated as "no
+            // v1.x data" (fresh install).
+            if (p.extension() == ".dat" ||
+                p.filename().string().rfind("snapshot-", 0) == 0) {
+                has_v1 = true;
+                break;
+            }
+        }
+    }
+
+    if (!has_v1) {
+        d.action = AutoMigrateDecision::Action::SkipNoV1Data;
+        d.message = "no v1.x snapshot data found — fresh install path";
+        return d;
+    }
+    if (opts.disabled) {
+        d.action = AutoMigrateDecision::Action::BlockedByFlag;
+        d.message = "v1.x data detected at '" + v1_data_dir +
+                    "' but --no-auto-migrate is set. Re-run without the flag "
+                    "or pass --migrate-from-v1 to force.";
+        return d;
+    }
+    d.action = AutoMigrateDecision::Action::RunMigration;
+    d.message = "v1.x data detected at '" + v1_data_dir +
+                "' — running one-shot migration";
+    return d;
+}
+
 MigrationResult migrate_v1_to_v2(
     const std::string& v1_data_dir,
     DocumentStore& target,

+ 39 - 0
service/src/storage/migrate_v1_to_v2.hpp

@@ -72,4 +72,43 @@ MigrationResult migrate_v1_to_v2(
 // any thread.
 bool migration_complete(LmdbEnv& env);
 
+// -------------------------------------------------------------------------
+// Task 3.2 — auto-detect-on-boot helper.
+// -------------------------------------------------------------------------
+
+// CLI / config-driven opts passed from main.cpp to should_run_migration().
+struct AutoMigrateOptions {
+    // Operator forces migration even if heuristics don't fire — useful for
+    // testing or non-standard data-dir layouts. Wired to --migrate-from-v1.
+    bool force = false;
+    // Operator forbids auto-migration. should_run_migration() returns
+    // BlockedByFlag when v1.x markers exist. Wired to --no-auto-migrate.
+    bool disabled = false;
+};
+
+struct AutoMigrateDecision {
+    enum class Action {
+        SkipNoV1Data,        // No v1.x markers — fresh install, proceed.
+        SkipAlreadyMigrated, // _meta marker present — proceed.
+        RunMigration,        // v1.x data present + permitted — run.
+        BlockedByFlag        // v1.x data present but --no-auto-migrate.
+    };
+    Action action = Action::SkipNoV1Data;
+    std::string message;  // operator-facing reason
+};
+
+// Decide whether to run migration on boot. Pure function over the data dir
+// contents + flags + env state — exposed for unit testing the auto-detect
+// logic without needing to drive main.cpp's signal/lifecycle path.
+//
+// Decision rules:
+//   - migration_complete(env)       → SkipAlreadyMigrated
+//   - opts.force                    → RunMigration (operator escape hatch)
+//   - No v1_data_dir / no snapshots → SkipNoV1Data
+//   - opts.disabled                 → BlockedByFlag
+//   - Otherwise                     → RunMigration
+AutoMigrateDecision should_run_migration(const std::string& v1_data_dir,
+                                          LmdbEnv& env,
+                                          const AutoMigrateOptions& opts = {});
+
 }  // namespace smartbotic::db::storage

+ 110 - 0
tests/test_migrate_v1_to_v2.cpp

@@ -43,6 +43,8 @@ using smartbotic::database::CollectionOptions;
 using smartbotic::database::Document;
 using smartbotic::database::MemoryStore;
 using smartbotic::database::SnapshotManager;
+using smartbotic::db::storage::AutoMigrateDecision;
+using smartbotic::db::storage::AutoMigrateOptions;
 using smartbotic::db::storage::DocumentStore;
 using smartbotic::db::storage::LmdbDocumentStore;
 using smartbotic::db::storage::LmdbEnv;
@@ -51,6 +53,7 @@ using smartbotic::db::storage::migrate_v1_to_v2;
 using smartbotic::db::storage::migration_complete;
 using smartbotic::db::storage::MigrationProgress;
 using smartbotic::db::storage::MigrationResult;
+using smartbotic::db::storage::should_run_migration;
 
 namespace {
 
@@ -407,6 +410,106 @@ void test_failure_preserves_v1_dir() {
     std::cout << "PASS: failure preserves v1 dir\n";
 }
 
+// -------------------------------------------------------------------------
+// Task 3.2: Auto-detect helper (should_run_migration)
+// -------------------------------------------------------------------------
+
+void test_no_v1_dir_present_skips_migration() {
+    auto dir = make_tmpdir("no-v1");
+    // Make the dir but don't create snapshots/.
+    TmpEnv env_dir("no-v1-env");
+    auto d = should_run_migration(dir, env_dir.env);
+    check(d.action == AutoMigrateDecision::Action::SkipNoV1Data,
+          "no v1 dir → SkipNoV1Data");
+
+    // Non-existent dir is also SkipNoV1Data.
+    auto d2 = should_run_migration("/tmp/this-does-not-exist-xyz", env_dir.env);
+    check(d2.action == AutoMigrateDecision::Action::SkipNoV1Data,
+          "non-existent dir → SkipNoV1Data");
+
+    std::error_code ec;
+    fs::remove_all(dir, ec);
+    std::cout << "PASS: no v1 dir present skips migration\n";
+}
+
+void test_v1_dir_present_triggers_migration() {
+    auto dir = make_tmpdir("v1-present");
+    {
+        V1Builder b(dir);
+        b.createCollection("c");
+        b.insert("c", makeDoc("d1", {{"v", 1}}));
+        b.writeSnapshot();
+    }
+
+    TmpEnv env_dir("v1-present-env");
+    auto d = should_run_migration(dir, env_dir.env);
+    check(d.action == AutoMigrateDecision::Action::RunMigration,
+          "v1 dir present → RunMigration");
+
+    std::error_code ec;
+    fs::remove_all(dir, ec);
+    std::cout << "PASS: v1 dir present triggers migration\n";
+}
+
+void test_no_auto_migrate_flag_blocks() {
+    auto dir = make_tmpdir("blocked");
+    {
+        V1Builder b(dir);
+        b.createCollection("c");
+        b.insert("c", makeDoc("d1", {{"v", 1}}));
+        b.writeSnapshot();
+    }
+
+    TmpEnv env_dir("blocked-env");
+    AutoMigrateOptions opts;
+    opts.disabled = true;
+    auto d = should_run_migration(dir, env_dir.env, opts);
+    check(d.action == AutoMigrateDecision::Action::BlockedByFlag,
+          "v1 dir + --no-auto-migrate → BlockedByFlag");
+    check(d.message.find("--no-auto-migrate") != std::string::npos,
+          "error message references flag");
+
+    std::error_code ec;
+    fs::remove_all(dir, ec);
+    std::cout << "PASS: --no-auto-migrate blocks\n";
+}
+
+void test_force_overrides_no_v1_check() {
+    auto dir = make_tmpdir("force");
+    TmpEnv env_dir("force-env");
+    AutoMigrateOptions opts;
+    opts.force = true;
+    // No snapshots dir → would normally SkipNoV1Data, but force overrides.
+    auto d = should_run_migration(dir, env_dir.env, opts);
+    check(d.action == AutoMigrateDecision::Action::RunMigration,
+          "--migrate-from-v1 forces RunMigration");
+
+    std::error_code ec;
+    fs::remove_all(dir, ec);
+    std::cout << "PASS: --migrate-from-v1 forces migration\n";
+}
+
+void test_already_migrated_short_circuit() {
+    auto dir = make_tmpdir("already");
+    {
+        V1Builder b(dir);
+        b.createCollection("c");
+        b.insert("c", makeDoc("d1", {{"v", 1}}));
+        b.writeSnapshot();
+    }
+    TmpEnv env_dir("already-env");
+    LmdbDocumentStore target(env_dir.env);
+    auto r = migrate_v1_to_v2(dir, target, env_dir.env);
+    check(r.success, "migration succeeds");
+
+    auto d = should_run_migration(dir, env_dir.env);
+    check(d.action == AutoMigrateDecision::Action::SkipAlreadyMigrated,
+          "marker present → SkipAlreadyMigrated");
+    std::error_code ec;
+    fs::remove_all(dir, ec);
+    std::cout << "PASS: already-migrated short-circuits\n";
+}
+
 // -------------------------------------------------------------------------
 // main()
 // -------------------------------------------------------------------------
@@ -422,6 +525,13 @@ int main() {
     test_progress_callback_invoked();
     test_failure_preserves_v1_dir();
 
+    // Task 3.2: Auto-detect helper
+    test_no_v1_dir_present_skips_migration();
+    test_v1_dir_present_triggers_migration();
+    test_no_auto_migrate_flag_blocks();
+    test_force_overrides_no_v1_check();
+    test_already_migrated_short_circuit();
+
     std::cout << "\nAll migrate_v1_to_v2 tests passed.\n";
     return 0;
 }