定期バッチの「全面書き戻し」が新規行を消す事故を、検知・修正・復旧する
スプレッドシートの定期更新バッチが開始時スナップショットを全面setValuesで書き戻すと、その間に挿入された行が無痕跡で消える。間欠的でエラーも出ないこの事故について、真因の確定、競合検知による修正、消えた分の洗い出しと復旧までを一続きで行う手順。洗い出しで必ず踏む偽陽性4類型と、二重照合・dry-run・read-backの型つき。
約15.8万トークンの節約 (API料金換算で約240円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「定期バッチの「全面書き戻し」が新規行を消す事故を、検知・修正・復旧する」は、Google WorkspaceカテゴリのAI指示書(MDファイル)です。スプレッドシートの定期更新バッチが開始時スナップショットを全面setValuesで書き戻すと、その間に挿入された行が無痕跡で消える。間欠的でエラーも出ないこの事故について、真因の確定、競合検知による修正、消えた分の洗い出しと復旧までを一続きで行う手順。洗い出しで必ず踏む偽陽性4類型と、二重照合・dry-run・read-backの型つき。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約15.8万トークン(API料金換算で約240円)・88%のトークンを節約できます。
- カテゴリ
- Google Workspace
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約18万トークン
- この巻物使用時
- 約2.2万トークン
- 節約量
- 約15.8万トークン (約240円)
- 更新日
- 2026-09-25
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/md-5e199702/raw を読み込んで、この指示書どおりに実装して"
中身
定期バッチの「全面書き戻し」が新規行を消す事故を、検知・修正・復旧する
この指示書が解く問題
Google スプレッドシートを業務の台帳にしていると、ほぼ必ずこの構造になる。
- 定期バッチ(時間トリガーの GAS)がシート全体を読み、各行を更新し、最後にまとめて書き戻す
- 別の処理(他シートのスクリプト、フォーム、人の手入力)が、同じシートに行を挿入する
このとき 定期バッチが「開始時のスナップショット」を持ったまま長時間処理し、最後に全面 setValues で書き戻すと、
その間に挿入された行は上書きされて消える。
厄介なのは次の3点で、これが発覚を何週間も遅らせる。
- 間欠的にしか起きない。バッチが走っていない時間帯に作られたデータは正常に残るので、 「たまに入らない」という報告になり、再現手順が作れない。
- エラーが出ない。
setValuesは成功する。ログも正常終了する。 - 「余剰行の削除」を足すと痕跡まで消える。行挿入でデータが1行ズレると末尾に余りが出る。 これを「掃除」するつもりで消す処理を足すと、ズレの証拠が消えて完全な無痕跡消失になる。
この指示書は、①真因の特定 ②消えなくする修正 ③消えた分の洗い出しと復旧 を一続きで行う手順である。
前提
- 台帳シート(以下「台帳」)に、時間トリガーで動く更新バッチがある
- 別のスクリプトが台帳の先頭行に
insertRowsBefore(2, 1)で行を追加している - スクリプトは
claspで管理されている(手作業コピペをしていない)
STEP 1: 真因を確定する(推測で直さない)
1-1. 台帳へ行を追加している箇所をすべて洗い出す
grep -rn "insertRowsBefore\|appendRow\|insertRowAfter" <スクリプトのディレクトリ>
行を追加する関数と、それを呼ぶ関数の呼び出し経路を紙に書く。
多くの場合、メニューやボタンから呼ばれる「親関数」が
前処理(); 本処理(); 台帳へ追加(); のような直列になっている。
この形は 本処理が例外やタイムアウトで落ちると台帳への追加に到達しないという別の欠陥も同時に抱えている。
1-2. 更新バッチの「読み」と「書き」の距離を測る
更新バッチのコードで次の2点の行番号を確認する。
- スナップショットを取る行:
sheet.getDataRange().getValues() - 書き戻す行:
sheet.getRange(2, 1, data.length, data[0].length).setValues(data)
この2点の間に、外部 API 呼び出し・他スプレッドシートのオープン・行ごとのループがあれば、 その処理時間ぶんだけ「消える窓」が開いている。 処理時間が数分〜数十分なら、業務時間中の書き込みは高確率で踏む。
1-3. 時期を突き合わせる
git log で、症状が出始めた時期の前後にその書き戻し処理へ入った変更を探す。
特に「余剰行の削除」「行数の正規化」「エラーセルの掃除」を足した変更は、
痕跡を消して症状を悪化させるので最有力候補になる。
ここまでで「スナップショット → 長時間処理 → 全面書き戻し」が確認できたら真因確定。 再現手順を作ろうとして時間を溶かさない。間欠障害は再現コストが高すぎる。
STEP 2: 消えなくする(受け側に競合検知を入れる)
方針: 書き戻す直前にシートを見直し、開始時と変わっていたら書き戻しそのものを諦める。 中途半端にマージしようとしない。諦めて次回に回すのが、データ消失より常に安い。
2-1. スナップショット時に「照合キー」も保持する
const data = sheet.getDataRange().getValues().slice(1)
// 競合検知用。行数と、行を一意に識別できる列(例: URL や ID が入っている列)を控える
const snapshotLastRow = sheet.getLastRow()
const snapshotKeys = data.map(r => String(r[KEY_COL_INDEX] || ""))
KEY_COL_INDEX は 行ごとに値が違い、バッチ自身が書き換えない列を選ぶ。
URL、発行コード、UUID などが向く。日付や金額は重複・更新が起きるので不可。
2-2. 書き戻し直前に照合し、食い違えば中止する
function exportToSheet(sheet, rows, guard) {
// …(行の組み立て・数式の設定などの既存処理)…
if (guard) {
const currentLastRow = sheet.getLastRow()
const currentKeys = sheet
.getRange(2, KEY_COL_INDEX + 1, Math.max(currentLastRow - 1, 1), 1)
.getValues()
.map(r => String(r[0] || ""))
let conflictReason = ""
if (currentLastRow !== guard.snapshotLastRow) {
conflictReason = `行数が ${guard.snapshotLastRow} → ${currentLastRow} に変化`
} else if (currentKeys.length !== guard.snapshotKeys.length) {
conflictReason = `キー列の件数が変化`
} else {
for (let i = 0; i < currentKeys.length; i++) {
if (currentKeys[i] !== guard.snapshotKeys[i]) {
conflictReason = `キー列が ${i + 2} 行目で不一致`
break
}
}
}
if (conflictReason !== "") {
console.log(`他の処理がシートを変更したため書き戻しを中止: ${conflictReason}`)
notifyConflict(conflictReason) // 既存の通知経路を使う。新設しない
return "aborted_conflict" // setValues も余剰行削除も一切しない
}
}
sheet.getRange(2, 1, rows.length, rows[0].length).setValues(rows)
SpreadsheetApp.flush()
// 余剰行の削除は、この照合を通過したときだけ実行する
}
2-3. 中止したときは進捗を進めない(ここを間違えると静かに取りこぼす)
分割実行するバッチは「どこまで処理したか」を進捗セルに持つことが多い。 中止したのに進捗だけ進めると、その回の更新内容が次回の対象から外れて永久に反映されない。 消失は直ったのに更新漏れという別の穴が空く。
const result = exportToSheet(sheet, rows, { snapshotLastRow, snapshotKeys })
if (result === "aborted_conflict") {
// 進捗は保存しない。次回に同じ範囲をやり直す
lock.releaseLock()
return "aborted_conflict"
}
progressSheet.getRange("A2").setValue(progress) // 成功したときだけ進める
この落とし穴は AI に実装させると高確率で残る。 進捗の保存が元々
exportToSheetの呼び出し前に書かれているため、 中止の分岐を足しても進捗保存はそのまま前に残る。レビューで必ず確認すること。
2-4. 送り側(行を追加する処理)も堅くする
受け側の修正だけでも消失は止まるが、送り側にも2点入れておく。
(a) 台帳への追加が必ず走るようにする
function 親関数() {
前処理()
try {
本処理() // 重い。例外やタイムアウトで落ちうる
} finally {
台帳へ追加() // 落ちても台帳への登録は必ず実行する
}
後処理()
}
(b) 書き込み後に read-back で確認し、黙って成功と言わない
let registered = false
for (let attempt = 0; attempt < 2 && !registered; attempt++) {
sheet.insertRowsBefore(2, 1)
sheet.getRange(2, 1, 1, row.length).setValues([row])
SpreadsheetApp.flush()
if (String(sheet.getRange(2, KEY_COL_INDEX + 1).getValue()) === myKey) { registered = true; break }
// 他の処理で行がずれただけかもしれないので、キー列全体を探す
const found = sheet.getRange(`${KEY_COL}:${KEY_COL}`).createTextFinder(myKey).findAll()
if (found.length > 0) { registered = true; break }
}
if (!registered) {
Browser.msgBox("台帳への登録に失敗しました。管理者に連絡してください。(キー: " + myKey + ")")
return
}
成功トーストを出して終わる実装は禁止。 消えたことが誰にも伝わらない状態が事故を長期化させる。
2-5. ついでに直す: 重い全行読み取り
「既に登録済みか」を調べるために台帳の全行を getValues() している箇所があれば、
createTextFinder に置き換える。数式(特に IMPORTRANGE / INDIRECT)が多いシートでは
全行読み取りだけで数分かかり、後続の処理が実行時間上限に達して到達しなくなる。
// Before: 全行読み取り(数分かかる)
const all = sheet.getRange(1, KEY_COL_INDEX + 1, sheet.getLastRow(), 1).getValues()
// After: TextFinder(一瞬)
const found = sheet.getRange(`${KEY_COL}:${KEY_COL}`).createTextFinder(key).matchEntireCell(false).findAll()
STEP 3: 消えた分を洗い出す(ここで必ず偽陽性を踏む)
「元データは存在するのに台帳に無い」ものを機械的に列挙する読み取り専用コマンドを作る。
/** 台帳に無い元データを列挙する。書き込みはしない。 */
function reportMissingRows(sinceIso) {
const sinceDate = new Date(sinceIso)
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME)
const missing = []
const files = DriveApp.searchFiles(
'mimeType = "application/vnd.google-apps.spreadsheet" and title contains "<元データの名前の一部>"'
)
while (files.hasNext()) {
const file = files.next()
if (file.getDateCreated().getTime() < sinceDate.getTime()) continue
const found = sheet.getRange(`${KEY_COL}:${KEY_COL}`).createTextFinder(file.getId()).findAll()
if (found.length > 0) continue
missing.push({ name: file.getName(), created: file.getDateCreated(), url: file.getUrl() })
}
return { scanned: ..., missing: missing }
}
偽陽性の4類型(実例では25件中22件が偽陽性だった)
この選別を飛ばして機械的に復元すると、重複行を大量生産して事故が拡大する。
| 類型 | 見分け方 | 対応 |
|---|---|---|
| 1. 自動生成の空テンプレ | 名前が同一で毎日同じ時刻に作られている | 台帳に載らないのが正常。除外 |
| 2. 後続処理が未実行 | 台帳の行は「次の工程」を実行して初めて生まれる仕様 | 元データ内の「工程完了の記録セル」が空なら正常。除外 |
| 3. 記録セルがテンプレ原本を指す誤参照 | 記録セルにURLはあるが、参照先がテンプレ原本や別案件 | URLの有無だけで判定してはいけない(後述) |
| 4. 人が作った複製ファイル | 名前に「のコピー」等が付く | 元ファイルが台帳にあるので復元不要。除外 |
類型3 の確認手順(ここが最重要)
記録セル(例: 元データの 解説!C37 に後工程の成果物URLが入る)にURLが入っていることだけでは足りない。
参照先ファイルのメタデータまで取得し、名前が期待する命名規則に従い、当事者名が一致するかを確認する。
参照先のファイル名が
「<成果物の種別>(<コード>_<取引先名>様 _<案件名>)」
の形式で、かつ元データの取引先名と一致する → 本物。台帳に載るべき
参照先がテンプレ原本(作成日が何年も前)/別の取引先名 → 誤参照。消失ではない別問題
テンプレ原本を指したままの記録セルは、テンプレをコピーして使う運用では必ず一定数発生する。
STEP 4: 復元する(二重照合と dry-run を必ず通す)
復元コマンドは次の3つを必ず備える。
function restoreRow(sourceUrl, dryRun) {
const sourceId = (sourceUrl.match(/\/spreadsheets\/d\/([^/]+)/) || [])[1]
if (!sourceId) return { ok: false, error: "URLが不正です" }
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME)
// ① 二重照合。元データのキーと、成果物のキーの両方で既登録を確認する
const bySource = sheet.getRange(`${SRC_COL}:${SRC_COL}`).createTextFinder(sourceId).findAll()
if (bySource.length > 0) {
return { ok: true, skipped: true, reason: "元データ列に登録済み", rows: bySource.map(c => c.getRow()) }
}
const resultUrl = ss.getSheetByName("<記録シート>").getRange("<記録セル>").getValue()
const resultId = (String(resultUrl).match(/\/spreadsheets\/d\/([^/]+)/) || [])[1]
if (resultId) {
const byResult = sheet.getRange(`${RES_COL}:${RES_COL}`).createTextFinder(resultId).findAll()
if (byResult.length > 0) {
return { ok: true, skipped: true, reason: "成果物列に登録済み", rows: byResult.map(c => c.getRow()) }
}
}
// ② 行の組み立ては、通常の追加処理と「完全に同じ列構成・同じ数式」にする
const row = [ /* …通常の追加処理からそのまま写す… */ ]
if (dryRun) return { ok: true, dryRun: true, row: row }
sheet.insertRowsBefore(2, 1)
sheet.getRange(2, 1, 1, row.length).setValues(row)
SpreadsheetApp.flush()
// ③ read-back verify
const written = String(sheet.getRange(2, SRC_COL_INDEX + 1).getValue() || "")
return { ok: true, row: 2, verified: written.indexOf(sourceId) >= 0, written: written }
}
必ず dryRun: true で1件流し、組み立てた行が既存行と同じ形かを目視してから本実行する。
列のズレや数式の行番号のミスは、dry-run を飛ばすと本番データに直接入る。
復元後は STEP 3 の洗い出しをもう一度流し、対象が減っていることを確認する。 「復元した」と言うだけで再走査しないのは検証していないのと同じ。
STEP 5: 反映前の安全確認(スクリプトを上書きして他人の修正を消さない)
clasp push はリモートを無条件で上書きする。他の人がエディタ上で直接直していた場合、その修正は消える。
# 1. 作業用の別ディレクトリに .clasp.json だけ置いて、リモートの現状を取得
mkdir /tmp/remote-check && cp <project>/.clasp.json /tmp/remote-check/
cd /tmp/remote-check && npx --yes @google/clasp pull
# 2. ローカルと全ファイル比較し、差分が「自分の変更だけ」であることを確認
diff -r /tmp/remote-check <project>
# 3. 想定外の差分があれば push せずに止める。無ければ取得したリモート版をバックアップして push
cp -r /tmp/remote-check <backup>/remote-YYYYMMDD/
cd <project> && npx --yes @google/clasp push -f
# 4. read-back: push 後に pull し直し、修正が残っていることを確認する
npx --yes @google/clasp pull
grep -c "<追加した目印の文字列>" <変更したファイル>
注意: pull が拡張子違いの重複ファイルを作る
ローカルで .gs を使っていると、clasp pull は同じ内容を .js で落とし、
Foo.gs と Foo.js が並存する。次の push で同じ関数が二重定義されてスクリプト全体が壊れる。
pull のあとは必ず確認し、内容が同一なら一方を消す。
for f in Foo Bar; do
diff -q "$f.js" "$f.gs" >/dev/null 2>&1 && rm "$f.js"
done
再利用できる原則
- 長時間処理の先頭で取ったスナップショットを最後に全面書き戻す設計は、その間の外部書き込みを必ず消す。 マージを頑張るより「変わっていたら諦めて次回」が正しい。データ消失より再実行のほうが常に安い。
- 「掃除」の処理を足すときは、それが異常の痕跡を消していないかを問う。 ズレた余剰行は、ゴミである前に事故の証拠である。
- 中止の分岐を足したら、進捗・カウンタ・ログの更新も一緒に巻き戻す。 片方だけ進むと静かな取りこぼしになる。
- 「参照先URLが入っている」は「正しい参照先である」を意味しない。 参照先の実体を取得して名前まで確認する。
- 自動復元の前に二重照合と dry-run。 復元は本質的に重複を作る操作であり、判定を1本の条件に頼らない。
よくある質問
+「定期バッチの「全面書き戻し」が新規行を消す事故を、検知・修正・復旧する」とは何ですか?
スプレッドシートの定期更新バッチが開始時スナップショットを全面setValuesで書き戻すと、その間に挿入された行が無痕跡で消える。間欠的でエラーも出ないこの事故について、真因の確定、競合検知による修正、消えた分の洗い出しと復旧までを一続きで行う手順。洗い出しで必ず踏む偽陽性4類型と、二重照合・dry-run・read-backの型つき。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約18万トークンかかりますが、この巻物を使えば約2.2万トークンで済みます。差し引き約15.8万トークン(API料金換算で約240円)・88%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
GAS完全自動化テンプレ — Driveコマンドキュー方式
Google Apps Script の「毎回エディタで▶実行」を根絶。Drive 経由のコマンドキューで、初回1クリック以降は AI がすべての GAS 関数をリモート実行できるようになるテンプレート指示書。
人間の手入力台帳を壊さずに自動更新する — GAS Web App upsert 設計
各PC/各拠点の点検結果を、人間が手運用しているスプレッドシート台帳へ自動反映する。手入力列とコメントを絶対に壊さない突合設計、タブ/列の解決、並行POST対策、配布シークレットの落とし穴まで。
数式まみれの業務スプレッドシートを、Webアプリから壊さずに編集させる型
ArrayFormula と per-row 数式が混在する台帳を、セル単位 allowlist・dry-run 既定・適用前バックアップ・触っていないセルの数式不変検査で安全に書き換える設計手順。列ごとの数式復元と、テストが緑のまま壊れる典型例つき。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア