ai-chat-lite バックアップ設計

世代を分けて残し、失っても戻せる状態を保つ

📅 作成: 2026-08-30 / 更新: 2026-08-30

DB を定期的に控え、壊したり消したりしても戻せるようにする。実装済み。使い方はバックアップの手引きにある。この文書は、なぜこの形にしたかを残すもの。

目次

  1. なぜファイルコピーではだめか
  2. 4 つの世代
  3. 動かし方
  4. 戻し方
  5. 作るもの

なぜファイルコピーではだめか

_data\chat.db をコピーするだけでは中身がまるごと落ちる。実測で確かめた結果を残しておく。

取り方取れた件数備考
chat.db を単体コピー 0 件 本体は 24 KB しかなかった
VACUUM INTO 64 件 出力は 40 KB

WAL モードで動かしているため、書き込みは本体ではなく chat.db-wal に溜まる。測った時点では 2.6 MB あった。本体だけを複製すると、まだ統合されていない分が失われる。

DB は 3 つのファイルで 1 組

この文書を通しての前提になる。chat.db は 3 つのうちの 1 つでしかない。

ファイル役割欠けるとどうなるか
chat.db 本体。統合済みの内容が入る 何も読めない
chat.db-wal まだ本体に統合されていない書き込み 直近の発言がまとめて消える。実測ではここに全 64 件があった
chat.db-shm WAL を読むための索引。開いている間だけ要る 本体と WAL があれば作り直される

どれだけ -wal 側に溜まるかは、いつ統合されたかで決まる。「本体が大きいから中身も入っている」とは限らない。逆に本体が小さくても、統合直後なら中身は揃っている。見た目からは判断できない。

本体を残して -wal / -shm だけを消してはいけない。例外はない。消した分の書き込みは戻らない。邪魔に見えても消さず、捨てるときは 3 つまとめて捨て、残すときは 3 つまとめて残す

この 3 つを扱う場面は 2 つある。1 つは控えを取るときVACUUM INTO を使えば 3 つを織り込んだ 1 ファイルが得られる(この章)。もう 1 つは戻すときに現物を退避するとき。こちらは織り込む手段がないので、3 つまとめて移す(戻し方)。

VACUUM INTO を使う

VACUUM INTO は WAL を織り込んだ一貫したスナップショットを別ファイルに書く。

性質内容
サービスの停止要らない。読み取り専用の接続から実行でき、動いているサーバーの書き込みを邪魔しない
所要実測 24 ミリ秒
出力-wal-shm も伴わない。単体で開ける
断片化詰め直されるため、元より小さくなることがある

常に圧縮する

サイズに関わらず .zip にする。実測では 40,960 バイトが 3,722 バイト(9.1 %)になった。

いまは小さいので圧縮しなくても困らないが、後から方針を変えると過去の世代と形式が混ざる。戻す手順が世代によって変わるのは避けたい。最初から揃えておく。

zip の中身は chat.db という名前で固定する。展開してそのまま _data へ置けるようにするため。世代を区別する日時は zip 側の名前に付ける。

CSV にしない理由

本文は Markdown を想定しており、改行やカンマ、引用符がそのまま入る。CSV に落とすと往復で壊れる余地が残る。DB の形のまま持つのが最も確実で、戻すのも速い。

4 つの世代

取る間隔と残す数を変えた 4 つの区分を持つ。直近は細かく、古いものは粗く残す。

区分取る時刻残す数遡れる範囲使いどころ
hourly毎時 0 分 88 時間 いま壊した。作業前の状態に戻したい
daily毎日 0 時 1 分 71 週間 数日前に消したものを探したい
weekly月曜 0 時 2 分 41 か月 先月の状態を見たい
monthly1 日 0 時 3 分 6半年 ずっと前の記録を掘り返したい

合計 25 世代hourly を 8 本持たせると、朝から夕方までの作業がひととおり収まる。monthly の 6 本は半年分にあたる。

どれくらいの大きさになるか

実際に流れそうな本文(日本語混じりの Markdown、相槌から表やコード付きの報告まで、平均 81 文字)で作った DB を測った。

発言数DB1 世代(zip)25 世代の合計
1,000274 KB 17 KB430 KB
10,0002.5 MB 128 KB3.2 MB
100,00025.8 MB 1.2 MB30 MB

1 日 100 発言なら 10 万件に届くまで 3 年近くかかる。その時点でも 30 MB で、置き場に困る量ではない。

この圧縮率は良く出すぎている可能性がある。測るのに使った本文は同じ文面を繰り返し並べたもので、実際の会話より重複が多い。zip は繰り返しに強いため、本物のやり取りでは 2 倍前後まで膨らむと見ておく。それでも桁は変わらない。

置き場

区分ごとにフォルダを分ける。混ぜると、どれを消してよいか一目で分からなくなる。

_backup\
  hourly\   chat-20260830-140000.db.zip
  daily\    chat-20260830-000100.db.zip
  weekly\   chat-20260825-000200.db.zip
  monthly\  chat-20260801-000300.db.zip

_backup は先頭が _ なので Git 管理外になる。

名前で並べる

ファイル名は chat-yyyymmdd-hhmmss.db.zip。桁を揃えてあるので辞書順に並べれば時系列順になる。世代を整理するとき、新しい順に並べて上から N 個を残せばよい。

ファイルの更新日時では並べない。コピーや展開、バックアップソフトの復元で簡単に変わる。名前に入れた日時だけが、取った時点を正しく表す。

動かし方

タスクスケジューラに登録する。管理者権限は要らない。ログオンしているユーザーのタスクとして動く。

タスクを 4 つに分ける

1 つのタスクを毎時動かして区分を判定する作りにはしない。判定を自前で持つと「0 時のタスクが 1 時に走ったら daily と見なすのか」を自分で解くことになる。StartWhenAvailable は区分ごとに独立して取り返すため、そちらに任せる。

スクリプトは区分を引数で受け取るだけで済み、区分ごとに止めたり時刻を変えたりもできる

時刻を 1 分ずつずらす

毎時 0 分・0 時 1 分・0 時 2 分・0 時 3 分と間を空ける。同時刻にすると 4 つが同じ DB を同時に読み、ファイル名の秒まで一致して衝突する。

月曜 0 時には 4 つすべてが順に走る。1 分ずつずれているため重ならない。

専用のフォルダに置く

タスクスケジューラの \ai-chat-lite\ フォルダに作る。ルート直下には置かない。Windows が既定で持っているタスクに紛れると、どれが自分のものか見分けがつかなくなる。

フォルダは登録のときに自動で作られる。管理者権限は要らない。

-TaskPath を省くと見つからない。フォルダに入れたタスクは、名前だけで Get-ScheduledTaskUnregister-ScheduledTask を呼んでも 0 件になる。登録・照会・解除のすべてで同じパスを渡す。

タスク名

ai-chat-lite バックアップ 00時00分 (毎時)
ai-chat-lite バックアップ 00時01分 (毎日)
ai-chat-lite バックアップ 00時02分 (毎週)
ai-chat-lite バックアップ 00時03分 (毎月)

時刻を先に置くと、名前順がそのまま実行順になる。区分名だけでは文字コード順に並び、実行順とは無関係になる。

00:00 とは書けない。タスク名はそのままファイル名になるため : が使えず、「パラメーターが間違っています」で落ちる

フォルダの消し方

Unregister-ScheduledTask はタスクを消すだけでフォルダを残す。消すコマンドレットが無いため COM を使う。

$svc = New-Object -ComObject Schedule.Service
$svc.Connect()
$svc.GetFolder('\').DeleteFolder('ai-chat-lite', 0)

中にタスクが残っていれば「ディレクトリが空ではありません」で拒否され、中身は無事。人が別のタスクを同じフォルダに入れていても巻き添えにしないので、解除の処理に組み込んでよい。

毎月のトリガは XML で作る

月次だけ作り方が違う。Register-ScheduledTask -Xml を使う。New-ScheduledTaskTrigger に月次の指定が無く、MSFT_TaskMonthlyTrigger を組み立てても「パラメーターが間違っています」で受け付けられない。

Register-ScheduledTask には -ErrorAction Stop を付ける。$ErrorActionPreference = 'Stop' を置いていても失敗時に次の行へ進み、登録できていないのに「登録しました」と表示される

電源とネットワークの条件を外す

既定ではバッテリー駆動中にタスクが動かない。ノート PC で使うことを考え、次を指定する。

設定理由
StartWhenAvailable時刻を過ぎていたら、起きたあとに取り返す
AllowStartIfOnBatteriesバッテリー駆動でも動かす
DontStopIfGoingOnBatteries途中で電源が抜けても止めない
ExecutionTimeLimit10 分。掴んだまま残らないようにする

取ってはいけないときに取らない

2 つの場面で待つ。どちらも _data に置かれた印を見て判断する。

意味なぜ待つ必要があるか
MAINTENANCE DB を触っている最中 3 つのファイルが揃っていない瞬間がある。本体を差し替えた直後に古い -wal が残っている状態を複製すると、中途半端なものがバックアップとして残る。戻せると思っていたものが戻せない
BACKUP-RUNNING 別の区分が取っている最中 作業用のフォルダを取り合う。VACUUM INTO は出力先が既にあると失敗するため、両方とも取れずに終わることがある

待ち方

30 秒おきに 10 回まで見に行き、消えなければ諦める。最大 5 分待つ。

5 分を超えて DB を触る作業はそう頻繁には起きない。それでも取れなかったなら、その回の控えは無いhourly は 1 時間後に取り直せるが、monthly の次は翌月である。

終わり方終了コード理由
取れた0 待ったかどうかは問わない
印が消えず諦めた1 その回の控えが無い。理由がメンテナンスでも同じ。印の消し忘れに気づけるよう、タスクの履歴を赤くする
取得に失敗した1 DB が読めない、書き込めないなど

印の置き方

「空くのを待ってから置く」と書いてはいけない。確認してから置くまでの間に、もう一方が入り込む。置きに行って失敗したら待つ、の順にする。

置くときは wx フラグ(O_CREAT | O_EXCL)を使う。「無ければ作る」を OS が一続きの操作として保証するため、同時に取り合っても 1 つしか成功しない。実測で 10 個のプロセスが取り合っても成功は 1 つだけだった。

名前を変える方式(LOCK.<pid> を作って LOCKrename)は使わない。Node の renameSync と cmd の move先客を黙って上書きし、2 つとも「取れた」と思い込む。処理系ごとの実測はファイルで排他するにある。

握ったまま終わったとき

BACKUP-RUNNING は処理の最後で必ず消すが、強制終了させられると残る。残ったままだと、以降どの区分も 5 分待って諦め続ける。誰も気づかないまま、バックアップが 1 本も取れない状態が続きかねない。

印に書いた開始時刻を見て、30 分より古ければ残骸とみなして奪う。タスクの実行時間の上限を 10 分にしてあるので、正常に動いている処理が 30 分残ることはない。

作業用のフォルダも区分ごとに分ける

印は保険であり、そもそも取り合わないようにしておく。tmp\backup-work\<区分>\ と分けておけば、万一同時に走っても出力先が衝突しない。

印だけに頼らない。ファイルの有無で待たせる方法は、確認してから作るまでの間にもう一方が入り込む余地が残る。厳密な排他にはならないため、衝突しても壊れない作りを併せて持たせる。

記録を残す

タスクから走ると画面の出力は消え、残るのは LastTaskResult だけ。何件取れたか、なぜ取れなかったかを追えるようにする。バックアップと復旧の両方を対象にする。

決めたこと

項目内容理由
置き場 logs\yyyymm-backup-hourly-log.html
logs\yyyymm-backup-others-log.html
年月が先頭。名前で並べると時系列順になる。-log を付けて資料の html と区別する
形式 HTML。1 行を <div> 1 つで書く WARNERROR を色で目立たせる。テキストでは目を凝らさないと見分けられない
分け方 hourly は単独。daily / weekly / monthly と復旧は others hourly は他の 3 つを合わせた 20 倍以上。混ぜると monthly の年 12 行が埋もれる
保持 12 か月。先頭 6 桁で判断する 取得のたびに見る。専用の仕組みは持たない
1 回の実行で 1 行 毎時走るものが何行も書くと追いにくい。途中経過は画面にだけ出す
諦めたとき ERROR終了コードを非 0 にする。理由は問わない タスクの履歴が赤くなり、ログを開かずに気づける。印の消し忘れで控えが取れ続けない事態を見逃さない
書けないとき バックアップは続ける 記録が残らないより、控えが取れない方が困る
文字コード BOM 無し UTF-8 + CRLF 日本語を含む。<meta charset> で宣言する
消す対象 yyyymm-backup-*-log.html だけ サービスのログ(WinSW が書くもの)に触れない

書き方

先頭に <head><style> を 1 度だけ書き、以降は 1 行ずつ追記する。閉じタグは書かない。

<div class=I>2026-08-30 11:31:05.398 INFO  [hourly ] 取得 78 件 / 3,970 バイト / 10 ms / 8 世代を保持</div>
<div class=W>2026-08-30 13:00:31.042 WARN  [hourly ] 取得 79 件 / 4,012 バイト / 11 ms / 別のバックアップを 30 秒待機</div>
<div class=E>2026-08-30 14:05:00.911 ERROR [hourly ] 諦め メンテナンスの印が 5 分経っても消えない</div>
取り決め内容
class I / W / Eクォートで囲まないclass=I)。値に記号を含まないため省ける
閉じタグ 行末の </div> だけ書く。</body></html> は書かない。ブラウザが補完する
エスケープ & < > の 3 文字。この順で置き換える& を後にすると二重に変換される)
本文の中身 テキストのログと同じ。先頭が固定長の JST 日時、レベル 5 文字、区分は固定幅

実測

ブラウザで実際に確かめた(7 件すべて通過)。

確かめたこと結果
閉じタグを書かない 補完される。行がすべて要素として取れる
class=I とクォート無し 効く。.I .W .E で選べる
行の書きかけで開く 壊れない。途中まで書かれた行も、そこまでは見える
記号を含む本文 エスケープすれば要素として解釈されない
1000 行 115 ミリ秒 / 70 KB。hourly は月 744 行なので 55 KB 程度

書き込みの途中で開いても壊れないことが要点。ログは書かれ続けるファイルなので、いつ開かれるか分からない。閉じタグを待たずに表示できる作りにしておく。

レベルの意味

控えが取れたかどうかで決まる。取れなかったなら理由によらず E

場面待って取れた5 分待っても取れなかった
MAINTENANCE があった I E
BACKUP-RUNNING があった W E

メンテナンスの印を消し忘れると、以後すべての控えが取れなくなる。これを I で流すと、毎時 I が並ぶだけで異常に見えない。数か月後に戻そうとして、控えが 1 本も無いことに気づく。諦めは MAINTENANCE が理由でも E にする。

レベル意味
Iなし 取れた。戻した。人が DB を触っている間に待つのは設計どおりの動作
W 起きないはずのことが起きている。4 区分は 1 分ずらしてあり、1 回の取得は 1 秒とかからない。待たされたなら前の実行が 60 倍以上長引いている
E その回の控えが無い。monthly なら次は翌月で、半年分のうち 1 本が空く

手で取るとき

区分を指定して呼ぶ。作業前に 1 本取っておきたいときは hourly を使う。

.\backup.ps1 -Kind hourly

戻し方

動いているサーバーに DB を掴まれたままでは入れ替えられない。メンテナンスの印と組み合わせる。

することなぜ
1_data\MAINTENANCE を置く この印がある間、サーバーは起動せず待機する
2chat.mjs restart で落とす DB を手放させる。印があるので待機に入る
3zip を展開し、中身を確かめる 壊れた zip で上書きしないため
4いまの DB を退避し、入れ替える 戻した中身が違ったとき、元に戻せるようにする
5印を消す サーバーが自分から起動する

stop ではなく restart を使う。ここは間違えやすく、実際に一度サービスを止めてしまった。

stop は終了コード 0 で終わる。WinSW はそれを正常終了とみなし、サービスごと停止する。そうなると印を消しても誰も起動せず、復旧には管理者権限での再開が要る。

restart は終了コード 1。異常終了として扱われ、WinSW が 10 秒後に起動し直す。そのとき印があれば待機に入り、消えた時点で動き出す。メンテナンスの印は、この再起動ループがあって初めて働く。

いまの DB は 3 つとも残す

_data\prev-yyyymmdd-hhmmss\ を作り、chat.db / chat.db-wal / chat.db-shm をまとめて移す。1 つでも欠けると戻せない。

chat.db だけを残しても意味がない。最初にこれをやって実害が出た。-wal を消したうえで本体だけを退避したところ、退避したものは読み込めても 0 件だった。発言 64 件はすべて -wal 側にあり、消した時点で戻す手段が失われていた。戻した中身が正しかったので事なきを得たが、違っていれば取り返しがつかなかった。

個別に名前を変える方法(chat.db-wal.prev-…)は採らない。_data 直下が散らかり、どれが同じ組なのか名前を突き合わせないと分からなくなる。フォルダに入れれば一目で揃う。

終了時に DB を閉じる

終了の処理で closeDb() を呼ぶ。書き込みができる状態で開いていて、かつ閉じたときに限り、WAL の内容が本体に統合されて 3 つが 1 つにまとまる。

開き方終わり方chat.db-wal / -shm
書き込み可close() する 4 KB → 114 KB消える
書き込み可閉じずに終了 4 KB のまま2.2 MB 残る
読み取り専用どちらも 変わらない残る

閉じられなかった場合も終了は続ける。次に開いたとき SQLite が自分で復旧するため。退避は 3 つとも行う。強制終了では閉じる処理を通らず、-wal が残っている前提で扱う必要がある。

読み取り専用で開くと、閉じても -wal-shm が残る。控えの中身を確かめるだけでもこの 2 つが増えるが、増えたものを消してはいけない。元からあったものか自分が作ったものか区別できない。確認するならコピーを作ってそちらを開く。

作るもの

src/server/backup.mjs           … VACUUM INTO・世代の整理・印による排他
tools/80_ops/
  backup.mjs                    … 印を見て待ち、スナップショットを取る
  backup.ps1 / .cmd             … 区分を受け取り、圧縮して世代を整理する
  restore.ps1 / .cmd            … 戻す。3 つまとめて退避する
  register-backup-tasks.ps1 / .cmd  … タスクを 4 つ登録する
  migrate-backup-layout.ps1 / .cmd  … 区分を分ける前のものを移す(1 度だけ)
  log.ps1                       … 取得と復旧の記録を HTML で残す
tests/backup.test.mjs           … 32 件
notes/90_rules/backup.html      … 運用の手引き

できていること

項目状態確かめたこと
取得・圧縮・世代整理 4 区分すべてで取得。hourly が 8 世代に切り詰まる
印による排他 メンテナンス中・バックアップ中とも待って見送る
戻す手順 印を置いて落とし、3 つ退避して入れ替え、起動するまで
置き場の移行 8 件を hourly へ移し、最新 1 本を残り 3 区分へ配った
タスクの登録 \ai-chat-lite\ に 4 つ。手で起動して LastTaskResult = 0
テスト 32 件
記録を残す承認待ち 設計のみ。記録を残すを参照

置き場を移すとき

区分を分ける前の控えは _backup\hourly\ へ移す。取った間隔がまちまちなので、最も細かい区分に入れる。

そのうえでいちばん新しい 1 本を daily / weekly / monthly にも配る。配らないと monthly に 1 本目が入るのは翌月 1 日で、それまで戻せる先が hourly しかない。中身が同じ 3 本になるが、日が経てば区分ごとに別のものへ置き換わる。

移すのは zip なので 3 つのファイルを気にする必要はない。VACUUM INTO が書いた単体の chat.db だけが入っている。

決めていない点

論点
変化が無いとき 前回から発言が増えていなければ取らずに済ませるか、毎回取るか。毎回取ることを勧める。判定を持つと、判定自体が壊れたときに気づけない
失敗したとき タスクの履歴に残るだけで、こちらからは気づけない。当面は放置し、必要になったら考える