1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustでログ監視を実装するときにcursorとSQLite Transactionをどう設計したか

1
Posted at

常駐アプリでログファイルを監視していると、

ログを読む
↓
解析する
↓
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や常駐アプリを作る際の参考になれば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?