常駐アプリでログファイルを監視していると、
ログを読む
↓
解析する
↓
DBへ保存する
だけでは済まないことがあります。
特に厄介なのが、
- 同じログを二重処理したくない
- アプリ再起動後も続きから読みたい
- DB保存に失敗したログを飛ばしたくない
- APIが落ちてもローカル記録は続けたい
といったケースです。
今回、Tauri + RustでVRChatのログを監視するデスクトップアプリを作る中で、ログのcursorとSQLiteの保存結果を矛盾させない設計を実装しました。
この記事ではVRChat固有の解析方法ではなく、ログ監視アプリとして再利用しやすい部分を中心に紹介します。
やりたいこと
ログファイルへ追記される内容を定期的に読み取り、意味のあるイベントへ変換してSQLiteへ保存します。
Log File
↓
Reader
↓
Parser
↓
Domain Event
↓
SQLite
単純に見えますが、重要なのは、
どこまでログを読んだか
を正しく管理することです。
毎回ログ全体を読まない
ログ監視では、前回の読み取り位置をcursorとして保存します。
0 byte
↓
処理済み領域
↓
cursor
↓
未処理領域
↓
EOF
cursorが10,000なら、次回は10,000byte目以降を読みます。
概念的には、
struct LogCursor {
file_path: Option<PathBuf>,
offset: u64,
}
のような情報を保持します。
offsetだけではなくファイルパスも必要です。
ログローテーションなどで監視対象ファイルが変わった場合、同じoffsetを別ファイルへ適用してはいけないためです。
cursorをメモリだけに置くと再起動に弱い
最初に考えやすいのが、
let mut offset = 0;
のようなメモリ上だけのcursorです。
しかしアプリを終了すると値が消えます。
次回起動時に、
ログ先頭
↓
過去データを再処理
↓
DBへ重複INSERT
となる可能性があります。
そこでcursorをSQLiteへ永続化します。
実装では、ログcursorとアプリの現在状態をまとめてRuntimeStateとして扱っています。
struct RuntimeState {
log_cursor: Option<LogCursor>,
current_state: CurrentState,
}
再起動時はこの状態を読み込み、最後にcommit済みの位置から再開します。
ファイル末尾の行が完成しているとは限らない
ログファイルは別プロセスが書き込み続けています。
そのため、
2026-09-29 08:15:20 Some lo
のように、書き込み途中の行を読み込むことがあります。
ここでその行を処理してcursorを進めると、次回には残りだけを読むことになり、行が途中で分断されて正しく解析できなくなります。
そのため、
改行まで到達していない最後の行は処理済みにしない
ようにします。
完全な行
↓
処理
↓
cursorを進める
不完全な最終行
↓
処理しない
↓
次回もう一度読む
tail系の処理ではかなり重要なポイントです。
一番危険なのはcursorだけ進むこと
たとえば次の処理を考えます。
ログ読み込み
↓
cursor = 20000
↓
DB INSERT
このときDB INSERTに失敗した場合、
cursor = 20000
DBには保存されていない
という状態になります。
次回20,000byte以降から読み始めると、保存に失敗したデータを永久に飛ばしてしまいます。
つまり、
cursorもデータの一部として扱う必要があります。
DB保存結果とcursorを同じTransactionへ入れる
そこで、
イベント保存
RuntimeState保存
を同じSQLite Transaction内で行います。
ログを読む
↓
メモリ上のcursor更新
↓
BEGIN
イベント INSERT
RuntimeState UPSERT
COMMIT
正常にcommitされた場合だけ、
このイベントは保存済み
かつ
この位置までは処理済み
という状態になります。
途中で失敗すればTransactionをrollbackできます。
読み取り自体はTransaction外
今回の実装ではログファイルの読み込みはTransaction外で行っています。
そのため、
ログ読み込み
↓
メモリ上のcursorは進む
↓
SQLite Transaction開始
という順序です。
つまり、DB保存に失敗した直後は、
メモリ上cursor: 20000
DB上cursor: 10000
という差が発生します。
そこで一時的なSQLiteエラーの場合は、DBに保存されているRuntimeStateを読み直します。
SQLite BUSY / LOCKED
↓
ROLLBACK
↓
RuntimeState再読込
↓
cursor = 10000へ戻る
↓
再処理
最後にcommit済みの状態をチェックポイントとして使う形です。
リトライしてよいエラーだけを見極める
DBエラーだからといって、すべて無限に再試行するのも危険です。
今回の実装では、
SQLITE_BUSY
SQLITE_LOCKED
のような一時的なエラーではRuntimeStateを読み直して再試行します。
一方、
スキーマ不整合
データ破損
SQL文自体の誤り
など、再試行しても改善しない可能性が高いエラーではWatcherを停止します。
リトライ戦略は、
再実行すれば改善する可能性があるか
で分けた方が扱いやすいです。
DBをAPI同期のキューとして使う
保存したデータをWeb APIへ送りたい場合もあります。
ここでWatcher自身が、
ログ
↓
DB
↓
API
をすべて同期的に行うと、ネットワーク障害がログ監視まで止めてしまいます。
そこでAPI同期を別Workerへ分離します。
Watcher
↓
SQLite
↓
COMMIT
↓
通知
↓
Sync Worker
↓
未同期レコード取得
↓
API送信
↓
成功したら同期済みに更新
レコード側に、
synced_at = NULL
のような同期状態を持たせます。
API送信に失敗しても、データはSQLiteへ残っています。
次回、
WHERE synced_at IS NULL
で取得して再送できます。
SQLiteを永続化されたメッセージキューのように使う構成です。
API障害とログ監視を分離できる
この構成の利点は、外部APIが落ちてもログ監視を続けられることです。
Log File
↓
Watcher
↓
SQLite
API障害
×
Sync Worker
Watcherの責務はSQLiteまで。
Sync Workerの責務はSQLiteから外部サービスまで。
責務を分けることで、
ログを正しく記録すること
と
データを外部へ送ること
を独立して失敗・復旧させられます。
実装して分かったこと
ログ監視を始めるときは、
tail -f をRustで作ればいい
くらいに考えがちです。
実際には、
- cursor永続化
- ログローテーション
- 不完全な最終行
- 再起動
- DB保存失敗
- Transaction境界
- Retry
- 外部API障害
などを考える必要があります。
特に重要だったのは、
「どこまで読んだか」と「どこまで保存したか」を同じ状態として考えること
でした。
cursorは単なるReaderの内部情報ではなく、処理結果とセットで永続化する必要があります。
実際の利用例
今回この仕組みを使ったのは、VRChat向けサービス VRC World Shelf のCompanionアプリです。
VRChatがローカルへ出力するログをRustで監視し、
VRChat Log
↓
Tauri / Rust
↓
SQLite
↓
Sync Worker
↓
Laravel API
という構成でワールド訪問情報を記録しています。
実際のサービスはこちらです。
VRChat固有のログ解析については別の記事としてまとめています。
まとめ
Rustでログ監視を実装する際に、cursorとSQLite Transactionを組み合わせて処理位置と保存結果を一致させました。
ポイントは以下です。
- ログの読み取り位置を永続化する
- ファイルパスとoffsetをセットで持つ
- 不完全な最終行を処理済みにしない
- イベント保存とcursor更新を同じTransactionへ入れる
- DB保存に失敗したら最後のcommit地点へ戻れるようにする
- 一時エラーだけをリトライする
- API同期をWatcherから分離する
- SQLiteを永続的な同期キューとして使う
ログ監視そのものよりも、途中で失敗したときに同じ入力を安全に再処理できる設計の方が重要でした。
RustでログWatcherや常駐アプリを作る際の参考になれば幸いです。