CLI 1.1.13 の変更点に、バックグラウンドタスクとサブエージェントの起動のたびに同じ grant と settings が追記され、会話データベースが際限なく膨らんでいたという修正が入っていました。
心当たりがありました。個人開発で壁紙アプリを何本か運用しているのですが、素材の派生画像を8セット生成し、重複と制作国を検査し、30 カテゴリへ分類するところまでを、工程ごとにバックグラウンドタスクとサブエージェントへ分けて回しています。1日の起動回数がそれなりの数になる型の運用です。「起動のたびに積まれる」という不具合は、こういう回し方をしている側から先に効きます。
ただ、バージョンを上げれば直るのは「これから積まれる分」だけです。すでに積まれたものは残ります。そこで、上げる前に自分の環境で何がどれだけ積まれているのかを数えることにしました。
その過程で、削除と回収について自分が持っていた見積もりが二重に外れていることが分かりました。以下は、その二つの外れ方と、最終的に運用へ入れた手順です。
スキーマを知らないまま数える
最初に諦めたことがあります。会話データベースのテーブル構成をバージョンごとに追いかけるのはやめました。CLI は 1.1.12 から 1.1.13 へ数日で動いていますし、テーブル名や列名は内部実装であって、こちらが依存してよい対象ではありません。
代わりに、スキーマを一切知らない前提で数えられる方法だけを使います。SQLite には sqlite_master と dbstat があるので、テーブル名を知らなくてもページ単位の使用量が取れます。
読み取り専用で開く点も重要です。file:...?mode=ro の URI で開けば、CLI を起動したまま監査できます。書き込み側がコミット直前の排他区間に入っている一瞬を除けば読めますし、WAL モードなら書き込みトランザクションを保持している最中でも読めます。手元で両方のジャーナルモードで確認しました。
週次の棚卸しに回しているのは、次のスクリプトです。
#!/usr/bin/env python3
"""会話データベースの容量を、テーブル単位と重複追記の観点で棚卸しする。
使い方: python3 agdb_audit.py /path/to/conversation.db
読み取り専用で開くため、CLI を起動したままでも実行できる。"""
import sys, sqlite3, os
def open_ro(path):
# mode=ro で開く。書き込み側を止める必要がない。
return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
def table_bytes(cur):
"""dbstat があればテーブル別の実ページ使用量を返す。無ければ None。"""
try:
rows = cur.execute(
"SELECT name, SUM(pgsize) FROM dbstat GROUP BY name ORDER BY 2 DESC"
).fetchall()
except sqlite3.OperationalError:
return None
return [(n, b) for n, b in rows if b]
def table_bytes_fallback(cur):
"""dbstat 無しの環境向け。行データの実バイト長を列ごとに合算した概算。"""
out = []
names = [r[0] for r in cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
for n in names:
cnt = cur.execute(f'SELECT COUNT(*) FROM "{n}"').fetchone()[0]
if cnt == 0:
out.append((n, 0, 0))
continue
cols = [r[1] for r in cur.execute(f'PRAGMA table_info("{n}")')]
expr = " + ".join(f'COALESCE(LENGTH(CAST("{c}" AS BLOB)),0)' for c in cols)
total = cur.execute(f'SELECT SUM({expr}) FROM "{n}"').fetchone()[0] or 0
out.append((n, total, cnt))
return sorted(out, key=lambda r: -r[1])
def duplicate_report(cur, min_repeat=3, top=8):
"""主キー列を除く全列が一致する行を数える。
起動のたびに同じ内容が積まれる型の膨張は、ここに露出する。"""
result = []
names = [r[0] for r in cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
for n in names:
info = list(cur.execute(f'PRAGMA table_info("{n}")'))
cols = [r[1] for r in info if not r[5]] # r[5] が 1 なら主キー列
if not cols:
continue
key = ", ".join(f'"{c}"' for c in cols)
total = cur.execute(f'SELECT COUNT(*) FROM "{n}"').fetchone()[0]
if total == 0:
continue
distinct = cur.execute(
f'SELECT COUNT(*) FROM (SELECT DISTINCT {key} FROM "{n}")').fetchone()[0]
dup = total - distinct
if dup <= 0:
continue
worst = cur.execute(
f'SELECT COUNT(*) AS c, {key} FROM "{n}" GROUP BY {key} '
f'HAVING c >= ? ORDER BY c DESC LIMIT ?', (min_repeat, top)).fetchall()
result.append((n, total, distinct, dup, worst))
return sorted(result, key=lambda r: -r[3])
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(2)
path = sys.argv[1]
if not os.path.exists(path):
print(f"ファイルがありません: {path}")
sys.exit(1)
con = open_ro(path)
cur = con.cursor()
psize = cur.execute("PRAGMA page_size").fetchone()[0]
pages = cur.execute("PRAGMA page_count").fetchone()[0]
free = cur.execute("PRAGMA freelist_count").fetchone()[0]
size = os.path.getsize(path)
print(f"[file] {size:,} bytes / page_size={psize} / page_count={pages:,}")
print(f"[file] freelist={free:,} ページ = {free * psize:,} bytes")
print("\n[容量の内訳]")
tb = table_bytes(cur)
if tb:
for n, b in tb[:12]:
print(f" {b:>12,} bytes {b * 100.0 / size:5.1f}% {n}")
else:
print(" dbstat が無いため概算(行データの合計長)で表示します")
for n, b, cnt in table_bytes_fallback(cur)[:12]:
print(f" {b:>12,} bytes {cnt:>9,} rows {n}")
print("\n[重複追記の検出]")
rep = duplicate_report(cur)
if not rep:
print(" 完全一致の重複行はありません")
for n, total, distinct, dup, worst in rep[:6]:
print(f" {n}: {total:,} 行中 {dup:,} 行が重複"
f"(実質 {distinct:,} 種類 / 重複率 {dup * 100.0 / total:.1f}%)")
for row in worst[:3]:
vals = " | ".join(str(v)[:40] for v in row[1:])
print(f" x{row[0]:<5} {vals}")
con.close()
if __name__ == "__main__":
main()dbstat が使える環境と使えない環境
dbstat は仮想テーブルで、SQLITE_ENABLE_DBSTAT_VTAB を有効にしてビルドされた SQLite でのみ使えます。手元の Python 3 に同梱されている SQLite 3.37.2 では使えました。使えない環境では黙って落ちるのではなく、行データの実バイト長を列ごとに合算する概算へ切り替わるようにしてあります。
概算はページのオーバーヘッドとインデックスを含まないので、実ファイルサイズより小さく出ます。それでも「どのテーブルが支配的か」の順序は変わらないので、判断材料としては足ります。
実行結果の読み方
起動ごとの重複追記を再現したデータベース(会話 400 本ぶんに相当)に対して実行すると、こう出ます。
[file] 4,046,848 bytes / page_size=4096 / page_count=988
[file] freelist=0 ページ = 0 bytes
[容量の内訳]
2,469,888 bytes 61.0% messages
925,696 bytes 22.9% settings
618,496 bytes 15.3% grants
16,384 bytes 0.4% conversations
[重複追記の検出]
settings: 24,000 行中 12,000 行が重複(実質 12,000 種類 / 重複率 50.0%)
grants: 9,600 行中 8,800 行が重複(実質 800 種類 / 重複率 91.7%)
x12 conv-0000 | tool.run_command | {"allow": ["git status", "npm test"]}
x12 conv-0000 | tool.write_file | {"allow": ["src/**"]}
重複率 91.7% という数字が目に入ります。ここから手を付けたくなりますし、私も最初はそうしました。
なお、この記事に載せている数値は、公開されている修正内容と同じ追記パターンを手元で再現したデータベースを測ったものです。製品側のデータベースそのものを計測した値ではありません。挙動の仕組みを確かめるための再現であって、実機の実測値として読まないでください。
削除しただけでは、ファイルは 1 バイトも縮みません
重複行を消して、ファイルサイズを見ました。変わっていませんでした。
3 通りの削除を、それぞれ同じ元データベースの複製に対して試しています。
| 削除の内容 | 削除前 | 削除直後 | freelist の増加 |
|---|---|---|---|
| grants の重複のみ削除 | 4,046,848 bytes | 4,046,848 bytes | 135 ページ(552,960 bytes) |
| settings の重複のみ削除 | 4,046,848 bytes | 4,046,848 bytes | 1 ページ(4,096 bytes) |
| 古い会話を半数削除(本文ごと) | 4,046,848 bytes | 4,046,848 bytes | 305 ページ(1,249,280 bytes) |
SQLite の DELETE は、空いたページをフリーリストへ返すだけで、ファイル自体は切り詰めません。次に行が増えたときに再利用するためです。単一プロセスのアプリケーションとしては合理的な設計ですが、「ディスクが減っているので消した」という目的からすると、削除は何も達成していないことになります。
ここが一つ目の外れでした。掃除した気になって、ディスクの空きはまったく増えていません。