MongoDB 업그레이드 시 데이터 무결성 체크

taeni·2025년 10월 16일

데이터 입력

/* ===== 00_insert_verify_data.js ===== */
/* 목적: 업그레이드 전후 검증용 더미데이터 생성 (verify_* 네임스페이스) */

const DB_NAME = "prd-sre-test-jti-create-test002";
const PREFIX = "verify_";

const dbx = db.getSiblingDB(DB_NAME);
const colUsers = dbx.getCollection(PREFIX + "users");
const colProducts = dbx.getCollection(PREFIX + "products");
const colOrders = dbx.getCollection(PREFIX + "orders");

const COUNTS = { users: 2000, products: 500, orders: 7000 };
const BATCH_SIZE = 500;

function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function randomString(len) {
  const chars = "abcdefghijklmnopqrstuvwxyz";
  let s = ""; for (let i = 0; i < len; i++) s += chars.charAt(Math.floor(Math.random()*chars.length));
  return s;
}
function randomTags() {
  const tags = ["mochi","fruit","gift","dessert","coffee","kids","premium"];
  const n = randInt(1,3);
  return Array.from({length:n}, () => pick(tags));
}

// 인덱스
colUsers.createIndex({ email: 1 }, { unique: true });
colProducts.createIndex({ sku: 1 }, { unique: true });
colOrders.createIndex({ userId: 1, createdAt: -1 });

// Users
print("== inserting users ==");
let users = [];
for (let i = 0; i < COUNTS.users; i++) {
  users.push({
    _id: new ObjectId(),
    email: `user${i}@test.com`,
    name: `User ${i}`,
    age: randInt(18,60),
    joinedAt: new Date(Date.now() - randInt(0,1000)*86400000),
    city: pick(["Seoul","Suwon","Busan","Incheon"]),
    tier: pick(["basic","silver","gold","vip"])
  });
  if (users.length >= BATCH_SIZE) { colUsers.insertMany(users, { ordered:false }); users=[]; }
}
if (users.length) colUsers.insertMany(users, { ordered:false });

// Products
print("== inserting products ==");
let products = [];
for (let i = 0; i < COUNTS.products; i++) {
  products.push({
    _id: new ObjectId(),
    sku: `SKU-${String(i).padStart(5,"0")}`,
    name: `Product ${i} ${randomString(randInt(3,12))}`,
    price: Math.round((Math.random()*50000 + 5000))/100,
    tags: randomTags(),
    createdAt: new Date(Date.now() - randInt(0,365)*86400000)
  });
  if (products.length >= BATCH_SIZE) { colProducts.insertMany(products, { ordered:false }); products=[]; }
}
if (products.length) colProducts.insertMany(products, { ordered:false });

// Orders
print("== inserting orders ==");
const userIds = colUsers.find({}, { _id:1 }).toArray().map(d=>d._id);
const skus = colProducts.find({}, { sku:1 }).toArray().map(d=>d.sku);

let orders = [];
for (let i = 0; i < COUNTS.orders; i++) {
  const items = Array.from({length: randInt(1,3)}).map(()=>({
    sku: pick(skus),
    qty: randInt(1,5),
    price: Math.round((Math.random()*10000 + 500))/100
  }));
  orders.push({
    _id: new ObjectId(),
    userId: pick(userIds),
    items,
    total: items.reduce((a,b)=>a + b.qty*b.price, 0),
    status: pick(["new","paid","shipped","cancelled"]),
    createdAt: new Date(Date.now() - randInt(0,120)*86400000)
  });
  if (orders.length >= BATCH_SIZE) { colOrders.insertMany(orders, { ordered:false }); orders=[]; }
}
if (orders.length) colOrders.insertMany(orders, { ordered:false });

print("✅ verify data inserted");
print("counts:", {
  users: colUsers.estimatedDocumentCount(),
  products: colProducts.estimatedDocumentCount(),
  orders: colOrders.estimatedDocumentCount()
});

1. 업그레이드 “전” 스냅샷 (같은 DB 내 저장)

/* ===== 01_pre_snapshot_fixed.js ===== */

/* ===== 01_pre_snapshot_fixed.js ===== */

const DB_NAME = "prd-sre-test-jti-create-test002";
const SNAP_COLL = "__verify_snapshots";
const EXCLUDE_PREFIX = "__verify_";
const ALLOWLIST = []; // 필요 시 ["verify_users", ...] 처럼 제한

const dbx = db.getSiblingDB(DB_NAME);
const snaps = dbx.getCollection(SNAP_COLL);

function listCollections() {
  return dbx.runCommand({ listCollections: 1, nameOnly: false }).cursor.firstBatch;
}
function isIncluded(name) {
  if (name.startsWith(EXCLUDE_PREFIX)) return false;
  if (ALLOWLIST.length === 0) return true;
  return ALLOWLIST.includes(name);
}
function helloSafe() {
  try { return db.adminCommand({ hello: 1 }); }
  catch (e) { try { return db.isMaster(); } catch (e2) { return {}; } }
}
function isMongos(hello) {
  // mongos일 때 hello.msg === "isdbgrid" 가 통상적으로 존재
  return hello && (hello.msg === "isdbgrid");
}
function fcwSafe() { // FCV 안전 폴백
  try {
    const r = db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 });
    if (r && r.featureCompatibilityVersion) return { source: "getParameter", value: r.featureCompatibilityVersion };
  } catch (_) {}
  try {
    const doc = db.getSiblingDB("admin").system.version.findOne({ _id: "featureCompatibilityVersion" });
    if (doc) return { source: "system.version", value: { version: doc.version, targetVersion: doc.targetVersion ?? null } };
  } catch (_) {}
  try { return { source: "fallback", value: { serverVersion: db.version() } }; } catch (_) { return { source: "fallback", value: null }; }
}
function collStatsSafe(n) { try { return dbx.runCommand({ collStats: n, scale: 1 }); } catch (e) { return { ok:0, errmsg: e.message }; } }
function indexesSafe(n) { try { return dbx.getCollection(n).getIndexes(); } catch (e) { return [{ _error: e.message }]; } }

// dbHash 가능 시 사용
function dbHashSafe(names) {
  try { return dbx.runCommand({ dbHash: 1, collections: names, full: true }); }
  catch (e) { return { ok: 0, errmsg: e.message }; }
}

// mongos 폴백: 결정적 샘플 체크섬
function sampleChecksum(collName, opts = { limitPerSide: 1000, stride: 100 }) {
  const { limitPerSide, stride } = opts;
  const col = dbx.getCollection(collName);

  function rollingHashOfCursor(cur) {
    let h = 0; let i = 0;
    cur.forEach(doc => {
      if ((i++ % stride) !== 0) return; // stride 간격 샘플링(결정적)
      const s = JSON.stringify(doc); // 기본은 전체 문서 직렬화(필요시 프로젝션으로 조정)
      for (let k = 0; k < s.length; k++) h = ((h << 5) - h + s.charCodeAt(k)) | 0;
    });
    return h|0;
  }

  // 앞쪽 일부(_id 오름차순) + 뒤쪽 일부(_id 내림차순)를 합쳐서 결정적 샘플
  const fwd = col.find({}).sort({ _id: 1 }).limit(limitPerSide);
  const rev = col.find({}).sort({ _id: -1 }).limit(limitPerSide);

  const hf = rollingHashOfCursor(fwd);
  const hr = rollingHashOfCursor(rev);
  const combined = (((hf & 0xffff) << 16) ^ (hr & 0xffff)) | 0;

  return { hash32: combined, forwardSample: limitPerSide, backwardSample: limitPerSide, stride };
}

const snapshotId = new ObjectId();
print(`[PRE] start for ${DB_NAME}, snapshotId=${snapshotId}`);

const hello = helloSafe();
const atMongos = isMongos(hello);
const fcv = fcwSafe();

const colMetaAll = listCollections().map(c => ({
  name: c.name,
  type: c.type,
  options: {
    validator: c.options?.validator ?? null,
    timeseries: c.options?.timeseries ?? null,
    clusteredIndex: c.options?.clusteredIndex ?? null
  }
}));

const targetCols = colMetaAll.filter(c => c.type === "collection" && isIncluded(c.name)).map(c => c.name);

// 공통 수집
const stats = {}, indexes = {};
targetCols.forEach(n => { stats[n] = collStatsSafe(n); indexes[n] = indexesSafe(n); });

// 해시 수집(분기)
let hashMode = "";
let hashPayload = {};
if (!atMongos) {
  const dh = dbHashSafe(targetCols);
  if (dh && dh.ok === 1) {
    hashMode = "dbHash";
    hashPayload = dh; // { collections: {name:{md5:..}}, md5:.. }
  } else {
    hashMode = "sample32";
    // mongod인데도 실패하면 폴백
    const samples = {};
    targetCols.forEach(n => { samples[n] = sampleChecksum(n); });
    hashPayload = { samples };
  }
} else {
  hashMode = "sample32"; // mongos → dbHash 불가
  const samples = {};
  targetCols.forEach(n => { samples[n] = sampleChecksum(n); });
  hashPayload = { samples };
}

snaps.insertOne({
  _id: snapshotId,
  role: "pre",
  db: DB_NAME,
  createdAt: new Date(),
  hello,
  fcv,
  hashMode,         // "dbHash" | "sample32"
  hashPayload,      // dbHash 결과 or 샘플 해시 맵
  collections: colMetaAll,
  targetCols,
  stats,
  indexes
}, { writeConcern: { w: "majority" } });

print(`[PRE] saved (mode=${hashMode}).`);

2.업그레이드 “후” 비교 (같은 DB 내 저장)

/* ===== 02_post_compare_compass_fixed.js ===== */

(() => {
  const DB_NAME = "prd-sre-test-jti-create-test002";
  const SNAP_COLL = "__verify_snapshots";
  const EXCLUDE_PREFIX = "__verify_";
  const ALLOWLIST = [];

  const dbx = db.getSiblingDB(DB_NAME);
  const snaps = dbx.getCollection(SNAP_COLL);

  function latestPre() { return snaps.find({ db: DB_NAME, role: "pre" }).sort({ createdAt: -1 }).limit(1).toArray()[0] || null; }
  function listCollections() { return dbx.runCommand({ listCollections: 1, nameOnly: false }).cursor.firstBatch; }
  function isIncluded(name) { if (name.startsWith(EXCLUDE_PREFIX)) return false; if (ALLOWLIST.length===0) return true; return ALLOWLIST.includes(name); }
  function collStatsSafe(n){ try { return dbx.runCommand({ collStats: n, scale: 1 }); } catch(e){ return { ok:0, errmsg: e.message }; } }
  function indexesSafe(n){ try { return dbx.getCollection(n).getIndexes(); } catch(e){ return [{ _error: e.message }]; } }
  function normalizeIndex(ix){
    const keep={}; ["name","key","unique","partialFilterExpression","sparse","expireAfterSeconds","weights","default_language","language_override","textIndexVersion","wildcardProjection","collation"]
      .forEach(k=>{ if (ix[k] !== undefined) keep[k]=ix[k]; });
    return keep;
  }
  function helloSafe(){ try { return db.adminCommand({ hello: 1 }); } catch(e){ try{ return db.isMaster(); } catch(e2){ return {}; } } }
  function isMongos(hello){ return hello && (hello.msg === "isdbgrid"); }
  function dbHashSafe(names){ try { return dbx.runCommand({ dbHash: 1, collections: names, full: true }); } catch(e){ return { ok:0, errmsg:e.message }; } }

  // 샘플 체크섬 (PRE와 동일 방식 유지해야 비교 가능)
  function sampleChecksum(collName, opts = { limitPerSide: 1000, stride: 100 }) {
    const { limitPerSide, stride } = opts;
    const col = dbx.getCollection(collName);
    function rollingHashOfCursor(cur){
      let h=0, i=0; cur.forEach(doc=>{
        if ((i++ % stride) !== 0) return;
        const s = JSON.stringify(doc);
        for (let k=0;k<s.length;k++) h = ((h<<5) - h + s.charCodeAt(k)) | 0;
      }); return h|0;
    }
    const fwd = col.find({}).sort({ _id: 1 }).limit(limitPerSide);
    const rev = col.find({}).sort({ _id: -1 }).limit(limitPerSide);
    const hf = rollingHashOfCursor(fwd);
    const hr = rollingHashOfCursor(rev);
    const combined = (((hf & 0xffff) << 16) ^ (hr & 0xffff)) | 0;
    return { hash32: combined, forwardSample: limitPerSide, backwardSample: limitPerSide, stride };
  }

  const pre = latestPre();
  if (!pre) {
    print("ERROR: No PRE snapshot found. 먼저 01_pre_snapshot_fixed.js를 실행해 주세요.");
    return; // Compass 안전
  }

  print(`[POST] comparing with PRE ${pre._id} @ ${pre.createdAt}, mode=${pre.hashMode}`);

  const hello = helloSafe();
  const atMongos = isMongos(hello);

  const colMetaNowAll = listCollections().map(c => ({
    name: c.name, type: c.type,
    options: {
      validator: c.options?.validator ?? null,
      timeseries: c.options?.timeseries ?? null,
      clusteredIndex: c.options?.clusteredIndex ?? null
    }
  }));
  const targetColsNow = colMetaNowAll.filter(c => c.type==="collection" && isIncluded(c.name)).map(c => c.name);

  const statsNow = {}, indexesNow = {};
  targetColsNow.forEach(n => { statsNow[n] = collStatsSafe(n); indexesNow[n] = indexesSafe(n).map(normalizeIndex); });

  // === 비교 섹션 ===
  function diffCounts(preStats, nowStats){
    const out=[]; const names = new Set([...Object.keys(preStats||{}), ...Object.keys(nowStats||{})]);
    names.forEach(n=>{
      if (!isIncluded(n)) return;
      const a = preStats?.[n], b = nowStats?.[n];
      const ac = a?.count, bc = b?.count;
      if (ac !== bc) out.push({ collection:n, count: `${ac}${bc}` });
    }); return out;
  }
  function diffIndexes(preIx, nowIx){
    const out=[]; const names = new Set([...Object.keys(preIx||{}), ...Object.keys(nowIx||{})]);
    const sig = x => JSON.stringify(x);
    names.forEach(n=>{
      if (!isIncluded(n)) return;
      const A = (preIx?.[n]||[]).map(normalizeIndex);
      const B = (nowIx?.[n]||[]).map(normalizeIndex);
      const SA = new Set(A.map(sig)), SB = new Set(B.map(sig));
      const missing = A.filter(x=>!SB.has(sig(x)));
      const added = B.filter(x=>!SA.has(sig(x)));
      if (missing.length || added.length) out.push({ collection:n, missingFromPost:missing, addedInPost:added });
    }); return out;
  }
  function diffOptions(preColsAll, nowColsAll){
    const out=[]; const preMap=Object.fromEntries((preColsAll||[]).map(x=>[x.name,x])); const nowMap=Object.fromEntries((nowColsAll||[]).map(x=>[x.name,x]));
    const names=new Set([...Object.keys(preMap), ...Object.keys(nowMap)]);
    names.forEach(n=>{
      if (!isIncluded(n)) return;
      const a = preMap[n], b = nowMap[n];
      if (!a && b) out.push({ collection:n, change:"new collection" });
      else if (a && !b) out.push({ collection:n, change:"collection removed" });
      else if (a && b) {
        const diffs=[]; ["validator","timeseries","clusteredIndex"].forEach(k=>{
          if (JSON.stringify(a.options?.[k]) !== JSON.stringify(b.options?.[k])) diffs.push({ field:k, pre:a.options?.[k], post:b.options?.[k] });
        });
        if (diffs.length) out.push({ collection:n, diffs });
      }
    }); return out;
  }

  // 해시 비교 (모드에 따라)
  let hashDiff = [];
  if (pre.hashMode === "dbHash") {
    // 가능하면 dbHash 재사용(현재 mongod에서 실행일 때만)
    const dh = dbHashSafe(targetColsNow);
    if (dh && dh.ok === 1) {
      const preMap = pre.hashPayload?.collections || {};
      const nowMap = dh.collections || {};
      const names = new Set([...Object.keys(preMap), ...Object.keys(nowMap)]);
      names.forEach(n=>{
        if (!isIncluded(n)) return;
        const a = preMap[n], b = nowMap[n];
        if (!a && b) hashDiff.push({ collection:n, change:"added(no PRE hash)" });
        else if (a && !b) hashDiff.push({ collection:n, change:"removed(no POST hash)" });
        else if (a.md5 !== b.md5) hashDiff.push({ collection:n, change:`hash changed ${a.md5}${b.md5}` });
      });
    } else {
      // PRE는 dbHash였지만 지금은 mongos이거나 실패 → sample32 폴백으로 재검증
      const samplesNow = {};
      targetColsNow.forEach(n => { samplesNow[n] = sampleChecksum(n); });
      hashDiff.push({ note: "dbHash unavailable at POST; used sample32 fallback", samplesNow });
    }
  } else {
    // PRE가 sample32였음 → 같은 방식으로 비교
    const preSamples = pre.hashPayload?.samples || {};
    const diff = [];
    targetColsNow.forEach(n=>{
      if (!isIncluded(n)) return;
      const nowS = sampleChecksum(n);
      const preS = preSamples[n];
      if (!preS) diff.push({ collection:n, change:"no PRE sample" });
      else if (preS.hash32 !== nowS.hash32) diff.push({ collection:n, change:`sample hash ${preS.hash32}${nowS.hash32}`, pre:preS, post:nowS });
    });
    // 새로 생긴/사라진 컬렉션도 기록
    const preNames = new Set(Object.keys(preSamples));
    const nowNames = new Set(targetColsNow);
    [...nowNames].forEach(n=>{ if (!preNames.has(n)) diff.push({ collection:n, change:"added in POST" }); });
    [...preNames].forEach(n=>{ if (!nowNames.has(n)) diff.push({ collection:n, change:"removed in POST" }); });
    hashDiff = diff;
  }

  const statsDiff = diffCounts(pre.stats, statsNow);
  const idxDiff = diffIndexes(pre.indexes, indexesNow);
  const optDiff = diffOptions(pre.collections, colMetaNowAll);

  print("\n=== HASH DIFF ==="); printjson(hashDiff);
  print("\n=== COUNT/STATS DIFF ==="); printjson(statsDiff);
  print("\n=== INDEX DIFF ==="); printjson(idxDiff);
  print("\n=== COLLECTION OPTION DIFF ==="); printjson(optDiff);

  snaps.insertOne({
    role: "post",
    db: DB_NAME,
    createdAt: new Date(),
    compareWith: pre._id,
    postHello: hello,
    hashModePre: pre.hashMode,
    result: { hashDiff, statsDiff, idxDiff, optDiff },
    statsNow, indexesNow, colMetaNowAll
  }, { writeConcern: { w: "majority" } });

  print("\n[POST] saved & compare finished (mongos/mongod auto).");
})();
profile
정태인의 블로그

0개의 댓글