配送信頼性の改善:サイレントメッセージロスと重複配信の修正 - Delivery Reliability — Silent Message Loss & Duplicate Delivery
クラッシュ、アボート、サービス再起動時に発生するメッセージの消失、回復不能な配送失敗、重複配送を引き起こす4つのP0重大バグを修正します。
🔍 症状
問題 #29125 — ゲートウェイクラッシュ時のサイレントメッセージ消失
ゲートウェイのクラッシュ(プロセステルミネーション、SIGKILL、OOMkill)により、最新のユーザーメッセージがエラー表示なしで履歴から消失します。
$ openclaw status
Service: gateway
Status: RUNNING
Uptime: 4h 23m
Messages processed: 12,847
Messages failed: 0
$ openclaw history --user alice --limit 5
[2024-01-15T14:32:01Z] alice: "Meeting at 3pm confirmed"
[2024-01-15T14:31:58Z] alice: "Wait, which room?"
[2024-01-15T14:31:55Z] alice: "What's the room number?"
[2024-01-15T14:31:50Z] alice: "Where is the meeting?"
# The gateway crashed between 14:31:55 and 14:32:01
# "Meeting at 3pm confirmed" was received but never persisted問題 #29126 — プラグイン/チャンネルにおけるサイレント配信失敗
プラグインまたはチャンネルの配信失敗は、内部的に成功を返しながら、宛先に到達せずユーザーにエラーが伝わらないまま失敗します。
$ openclaw plugin list --channel telegram
PLUGIN STATUS DELIVERY LAST CHECK
telegram ACTIVE UNKNOWN 2024-01-15T14:30:00Z
$ openclaw events --plugin telegram --since 1h
TIMESTAMP EVENT DETAILS
2024-01-15T14:29:55Z message.sent msg_id=a1b2c3
2024-01-15T14:30:00Z plugin.error plugin=telegram (NO LOG OUTPUT)
# The telegram bot was kicked from the channel
# Error occurred but was swallowed, message marked as delivered問題 #29127 — 中止による部分的返信の再配信トリガー
ハンドラの abort() を呼び出しても、すでに部分的にフラッシュされた部分的返信がリカバリー経路によって再配信されるのを防ぎません。
# User sends message triggering long response
$ openclaw history --msg-id msg_abc123
msg_id: msg_abc123
user: alice
content: "Generate a 5000-word report"
status: delivered
delivered_at: 2024-01-15T14:35:00Z
# Handler starts processing, sends partial response "Generating report..."
# User aborts the request
$ openclaw abort msg_abc123
abort: OK
# After recovery timeout, partial message is re-delivered
$ openclaw history --msg-id msg_abc123
msg_id: msg_abc123
status: delivered
replies: ["Generating report...", "Generating report...", "Generating report..."]
# ^--- duplicated partial reply問題 #29128 — 再起動後にすでに配信済みメッセージのリプレイ
正常な再起動後、配信リカバリーシステムがすでに正常に配信済みのメッセージを再配信し、重複を発生させます。
$ openclaw restart --service gateway
[INFO] Starting delivery recovery...
[INFO] Replaying 47 unacknowledged messages
[INFO] Delivered: msg_001
[INFO] Delivered: msg_002
...
[INFO] Delivered: msg_047
$ openclaw history --user alice --since 1h
[14:30:00] msg_001: "Hello" (DUPLICATE - already delivered before restart)
[14:29:55] msg_002: "Are you there?" (DUPLICATE - already delivered before restart)
[14:29:50] msg_003: "Hi bot" (DUPLICATE - already delivered before restart)
# 47 messages all duplicated🧠 原因
アーキテクチャ概要
OpenClawは信頼性のために配信キューアーキテクチャを採用しています。メッセージは以下のパイプラインを通じて流れます:
[User Input] → [Gateway] → [Handler Queue] → [Plugin/Channel] → [External Service]
↓
[Delivery Queue] ← [Persistence Layer]
↓
[Acknowledgement Tracker]問題別根本原因分析
問題 #29125 — ゲートウェイクラッシュによるデータ損失
障害シーケンス:
- メッセージがゲートウェイに到着し、メモリ内バッファ(
gateway/buffer.ts)に保持される - メッセージがハンドラに転送されるが、永続化の前に肯定応答が送信される
- クラッシュ時、永続化の書き込みは完了しなかったため失われる
// gateway/handler.ts (BUGGY CODE PATH)
async function handleMessage(msg: Message): Promise {
// Step 1: Forward to handler
await dispatchToHandler(msg);
// Step 2: ACK immediately (BEFORE persistence)
await sendAck(msg.id); // ⚠️ Premature acknowledgement
// Step 3: Async persist (never completes on crash)
persistMessage(msg).catch(console.error); // ⚠️ Fire-and-forget
} 肯定応答がステップ2で送信されるが、永続化は非同期的に後で発生するため、Race condition(競合状態)が発生します。ステップ2と3の間のクラッシュはデータ損失をもたらします。
問題 #29126 — サイレント配信失敗
障害シーケンス:
- プラグインが外部サービス(例:Telegram API)にメッセージを配信する
- 外部サービスがエラーを返す(例:「ボットがキックされた」)
- エラーはキャッチされるが伝播されない — DEBUGレベルでのみログ出力
- 配信が内部ステータスで成功としてマークされる
// plugins/telegram/delivery.ts (BUGGY CODE PATH)
async function deliver(payload: Payload): Promise {
try {
const response = await telegramAPI.sendMessage(payload);
return { success: true, messageId: response.message_id };
} catch (error) {
// Error swallowed — only debug log
logger.debug('Telegram delivery issue', { error }); // ⚠️ Silent failure
return { success: true }; // ⚠️ False success
}
} 呼び出し元は配信の確認として成功返り値を解釈し、リトライやアラートをしません。
問題 #29127 — 中止による部分的返信の再配信
障害シーケンス:
- ハンドラが処理を開始し、ストリーミング経由で部分的返信を送信する
- ユーザーが
abort()を呼び出し、ハンドラがキャンセル信号を受け取る - 中止ハンドラが
delivery_state = 'aborted'を設定する - リカバリーシステムがメッセージが未配信とみなす(ACK未受信のため)
- リカバリータイマーが発火し、部分的返信を再配信する
// core/delivery-queue.ts (BUGGY CODE PATH)
class DeliveryQueue {
async abort(messageId: string): Promise {
// Set abort flag
this.state.set(messageId, { status: 'aborted' });
// ⚠️ BUG: Does NOT update recovery index
// Recovery still thinks message needs delivery
// Cancel in-flight handler
await this.cancelHandler(messageId);
}
// Recovery timer checks this index
getPendingMessages(): string[] {
return this.state.entries()
.filter(e => e.status !== 'delivered') // ⚠️ 'aborted' passes filter
.map(e => e.messageId);
}
} リカバリーシステムは単純な status !== ‘delivered’ フィルタを使用しており、‘aborted’ メッセージも保留中として扱います。
問題 #29128 — 再起動後のリプレイ
障害シーケンス:
- メッセージがメモリ内で配信され、肯定応答される
- 正常なシャットダウンが開始される
- シャットダウンハンドラが永続化ステータスをクリアする(再起動高速化の最適化)
- 再起動時、永続化レイヤーが未肯定応答メッセージ 없다고報告する
- リカバリーシステムが最後の正常状態からすべてのメッセージをリプレイする
// core/graceful-shutdown.ts (BUGGY CODE PATH)
async function shutdown(): Promise {
// Stop accepting new messages
gateway.stop();
// Wait for in-flight deliveries
await deliveryQueue.drain();
// ⚠️ BUG: Clear acknowledged state before persist
// This is an "optimization" to reduce restart time
acknowledgedMessages.clear(); // ⚠️ Data loss
// Persist remaining unacknowledged only
await persistence.flush();
} この「最適化」は誤って配信済みメッセージをクリアし、リカバリーシステムにメッセージが配信されなかったと信じ込ませます。
🛠️ 解決手順
修正 #29125 — ゲートウェイクラッシュの永続化
修正前:
// gateway/handler.ts
async function handleMessage(msg: Message): Promise {
await dispatchToHandler(msg);
await sendAck(msg.id); // Premature ACK
persistMessage(msg).catch(console.error); // Async, unreliable
} 修正後:
// gateway/handler.ts
async function handleMessage(msg: Message): Promise {
// Step 1: Persist BEFORE acknowledgement
await persistMessage(msg);
// Step 2: Forward to handler
await dispatchToHandler(msg);
// Step 3: ACK only after persistence confirmed
await sendAck(msg.id);
} マルチステージCLI修正:
# Apply the persistence-first patch
$ openclaw patch apply --issue 29125 --component gateway
# Verify the patch
$ openclaw patch verify --issue 29125
[✓] Patched: gateway/handler.ts:persist-before-ack
[✓] Config: delivery.persist_before_ack=true
# Restart gateway to activate
$ openclaw restart --service gateway --mode=rolling修正 #29126 — サイレント配信失敗
修正前:
// plugins/telegram/delivery.ts
async function deliver(payload: Payload): Promise {
try {
const response = await telegramAPI.sendMessage(payload);
return { success: true, messageId: response.message_id };
} catch (error) {
logger.debug('Telegram delivery issue', { error });
return { success: true }; // False success
}
} 修正後:
// plugins/telegram/delivery.ts
async function deliver(payload: Payload): Promise {
try {
const response = await telegramAPI.sendMessage(payload);
return { success: true, messageId: response.message_id };
} catch (error) {
// Classify error severity
const isRetryable = isRetryableError(error);
// Log at appropriate level
if (isRetryable) {
logger.warn('Telegram delivery failed (retryable)', { error, payload });
} else {
logger.error('Telegram delivery failed (permanent)', { error, payload });
}
// Return actual failure status
return {
success: false,
error: error.message,
retryable: isRetryable
};
}
}
// Helper to classify Telegram errors
function isRetryableError(error: TelegramError): boolean {
const RETRYABLE_CODES = [429, 500, 502, 503, 504];
const NON_RETRYABLE_CODES = [400, 401, 403, 404, 403]; // bot kicked
if (RETRYABLE_CODES.includes(error.code)) return true;
if (error.message.includes('bot was blocked')) return false;
if (error.message.includes('chat not found')) return false;
if (NON_RETRYABLE_CODES.includes(error.code)) return false;
return true; // Default to retryable
} 設定の更新:
# Update openclaw.yaml
$ openclaw config set delivery.strict_failure_mode true
$ openclaw config set delivery.failure_notification_threshold 3
# Verify delivery monitoring
$ openclaw plugin config telegram --get failure_modes
{
"strict_failure_mode": true,
"notify_on_failure": true,
"failure_threshold": 3
}修正 #29127 — 中止による再配信の防止
修正前:
// core/delivery-queue.ts
class DeliveryQueue {
async abort(messageId: string): Promise {
this.state.set(messageId, { status: 'aborted' });
await this.cancelHandler(messageId);
// ⚠️ Missing: recovery index update
}
} 修正後:
// core/delivery-queue.ts
class DeliveryQueue {
async abort(messageId: string): Promise {
const state = this.state.get(messageId);
// Check if partial reply was already sent
if (state?.partialReplySent) {
// Mark as delivered to prevent recovery re-delivery
await this.markDelivered(messageId);
// Emit abort event for handler cleanup
await this.emitAbortEvent(messageId, {
reason: 'user_abort',
partialDelivered: true
});
} else {
// No partial reply — safe to mark as aborted
this.state.set(messageId, {
status: 'aborted',
abortedAt: Date.now()
});
// Update recovery index to exclude this message
this.recoveryIndex.remove(messageId);
await this.cancelHandler(messageId);
}
}
// Recovery system now checks recovery index, not status
getPendingMessages(): string[] {
return this.recoveryIndex.getAll();
}
} CLI修正:
# Apply abort handling patch
$ openclaw patch apply --issue 29127 --component delivery-queue
# Update recovery configuration
$ openclaw config set recovery.use_explicit_index true
$ openclaw config set recovery.abort_behavior preserve
# Clear existing corrupted state
$ openclaw recovery reset-state --force
# Verify fix
$ openclaw recovery status
Recovery Index: 247 messages tracked
Aborted Messages: 12 (properly excluded)修正 #29128 — 再起動後のリプレイ防止
修正前:
// core/graceful-shutdown.ts
async function shutdown(): Promise {
gateway.stop();
await deliveryQueue.drain();
// ⚠️ Clear acknowledged to speed up restart
acknowledgedMessages.clear();
await persistence.flush();
} 修正後:
// core/graceful-shutdown.ts
async function shutdown(): Promise {
gateway.stop();
// Wait for all deliveries to complete AND persist
await deliveryQueue.drain({
requirePersisted: true // Ensure all ACKs are persisted
});
// ⚠️ DO NOT clear acknowledged messages
// Preserve full delivery state for accurate recovery
// acknowledgedMessages.clear(); // REMOVED
// Ensure persistence includes all acknowledged messages
await persistence.flush({
includeAcknowledged: true // New: persist full state
});
} リカバリーインデックス修正:
// core/persistence.ts
async function persistFullState(): Promise {
const state = {
version: 2,
timestamp: Date.now(),
acknowledged: Array.from(acknowledgedMessages.entries()),
pending: Array.from(pendingMessages.entries()),
aborted: Array.from(abortedMessages.entries())
};
// Atomic write to prevent corruption
await atomicWrite(STORAGE_PATH, JSON.stringify(state));
} CLI修正:
# Apply shutdown persistence patch
$ openclaw patch apply --issue 29128 --component graceful-shutdown
# Migrate existing state to new format
$ openclaw maintenance migrate-state --format=v2
# Verify state integrity
$ openclaw state verify
State Version: 2
Acknowledged Messages: 1,247
Pending Messages: 0
Aborted Messages: 12
State Hash: a1b2c3d4e5f6...
$ openclaw restart --service gateway
[INFO] Starting delivery recovery...
[INFO] Restored state from disk (v2 format)
[INFO] Replaying 0 messages (all already delivered)🧪 検証
テスト #29125 — クラッシュ永続化
# 1. Start a message-heavy session
$ openclaw load-test --users 10 --duration 30s --rate 5
# 2. Simulate crash during active delivery
$ openclaw inject-fault --type=crash --service=gateway --delay=5s
# 3. Verify no message loss after restart
$ openclaw verify --check=message-integrity
[✓] Message count: 150 sent, 150 persisted
[✓] Sequence integrity: No gaps detected
[✓] Last message verified: msg_150期待される出力:
Test: Gateway Crash Persistence
Result: PASS
Messages Before Crash: 150
Messages After Recovery: 150
Lost Messages: 0
Persistence Rate: 100%テスト #29126 — 配信失敗の伝播
# 1. Trigger a permanent failure (bot kicked)
$ openclaw mock telegram --error="bot was kicked" --channel=test_channel
# 2. Send message that should fail
$ openclaw send --user alice --message "test" --channel telegram
# 3. Verify failure is reported
$ openclaw events --type=delivery_failure --since 1m
TIMESTAMP LEVEL EVENT DETAILS
2024-01-15T14:30:00Z WARN delivery.failed plugin=telegram
error="bot was kicked"
retryable=false
message=msg_test_001期待される出力:
Test: Delivery Failure Propagation
Result: PASS
Failure Detected: YES
Error Logged: YES (WARN level)
User Notified: YES
Retryable: NO
Message Status: failed_permanentテスト #29127 — 中止による再配信の防止
# 1. Start long-running handler
$ openclaw send --user alice --message "Generate 10000 words"
# 2. Send abort while processing
$ sleep 2 && openclaw abort --msg-id= --reason=timeout
# 3. Wait for recovery timeout
$ sleep 60
# 4. Check for duplicate messages
$ openclaw history --user alice --limit 5
[✓] No duplicate messages detected
[✓] Aborted message not re-delivered 期待される出力:
Test: Abort Re-Delivery Prevention
Result: PASS
Partial Reply Sent: YES
Abort Processed: YES
Re-delivery Attempted: NO
Duplicate Messages: 0
Recovery Index: Correctly excludes aborted messageテスト #29128 — リプレイ防止
# 1. Send and deliver several messages
$ for i in {1..50}; do openclaw send --user alice --message "Msg $i"; done
# 2. Verify all delivered
$ openclaw verify --delivered --user alice
Delivered Count: 50
# 3. Restart service
$ openclaw restart --service gateway
# 4. Check for duplicates
$ openclaw history --user alice --since 1m | grep -c "Msg"
50期待される出力:
Test: Restart Replay Prevention
Result: PASS
Messages Before Restart: 50
Messages After Restart: 50
Duplicate Count: 0
State Restored: YES (v2 format)
Recovery Replay: 0 messages完全統合テスト
# Run complete delivery reliability suite
$ openclaw test suite --name=delivery-reliability
Tests:
[✓] #29125 - Gateway crash persistence
[✓] #29126 - Delivery failure propagation
[✓] #29127 - Abort re-delivery prevention
[✓] #29128 - Restart replay prevention
[✓] Concurrent delivery stress test
[✓] Network partition recovery
[✓] Partial failure cascade
Result: 7/7 PASSED
Coverage: 100%⚠️ よくある落とし穴
環境別の落とし穴
Docker/Kubernetes デプロイメント
- シグナル処理: Docker stopはSIGTERMを送信しますが、10秒のタイムアウト後にSIGKILLでプロセスが終了される場合があります。
grace_period_secondsがdrain_timeoutより長く設定されていることを確認してください。# docker-compose.yml services: gateway: stop_grace_period: 30s # Must exceed drain_timeout command: openclaw gateway --drain-timeout=25s - ボリュームのパーミッション:異なるUIDでマウントされた場合、永続化ステータスが読み取れない場合があります。
# Verify permissions $ docker exec openclaw-gateway ls -la /data/state.json -rw-r--r-- 1 openclaw openclaw 4096 Jan 15 14:30 /data/state.json - メモリ圧:OOMキラーが永続化の完了前にゲートウェイをターゲットにする場合があります。バッファのあるメモリ制限を設定してください。
resources: limits: memory: 512Mi # Must exceed expected peak + state size reservations: memory: 256Mi
macOS 開発環境
- ファイルロック:macOS APFSはアトミックなファイル名の変更を正しくサポートしていない場合があります。明示的なfsyncを使用してください。
# Check if atomic writes work $ openclaw debug verify-atomic-write [✓] Atomic write verified on /tmp (APFS supports it) [✓] Atomic write verified on /var/tmp (APFS supports it) [!] Warning: /Users/... uses non-atomic filesystem - リソース制限:デフォルトのulimitは制限が厳しめです。高スループットテスト用に増加してください。
# Check current limits $ ulimit -n 256 # Too low for productionIncrease for session
$ ulimit -n 10240
Windows (WSL2)
- ファイルウォッチャー:WSL2ファイルウォッチャーは既知のパフォーマンス問題があります。
fs.inotify.max_user_watchesエミュレーションを無効にしてください。# In /etc/sysctl.conf fs.inotify.max_user_watches=524288 fs.inotify.max_user_instances=512 - 改行コード:設定ファイル内のCRLFがステータスJSONを破損する可能性があります。マウント時に正規化してください。
# Mount with consistent line endings mount --bind -o ro /mnt/c/config/openclaw.yaml /data/config.yaml
設定の誤り
早期肯定応答がまだ有効になっている
# ⚠️ WRONG: Still using old behavior
$ openclaw config get delivery.persist_before_ack
false # Bug not fixed
# ✓ CORRECT: Should be true
$ openclaw config set delivery.persist_before_ack true
$ openclaw restartリカバリーインデックスが移行されていない
# ⚠️ WRONG: Old index format still in use
$ openclaw recovery status
Index Format: legacy # Bug not fixed
# ✓ CORRECT: Migrate to new format
$ openclaw maintenance migrate-state --format=v2
$ openclaw restart失敗モード設定の不整合
# ⚠️ WRONG: Different failure modes across plugins
$ openclaw config get --plugin '*' delivery.strict_failure_mode
telegram: false
slack: true # Inconsistent!
discord: false
# ✓ CORRECT: Uniform configuration
$ openclaw config set --plugin '*' delivery.strict_failure_mode trueランタイムのエッジケース
- 永続化中のネットワーク分断:書き込み中にネットワークが失敗すると、ステータスファイルが破損する可能性があります。アトミック書き込みとバックアップを有効にしてください。
# Enable backup on corruption $ openclaw config set persistence.backup_on_corruption true $ openclaw config set persistence.backup_count 3 - クロックスキュー:リカバリー順序付けに使用されるタイムスタンプは、NTP補正後に競合する可能性があります。順序付けに論理的クロックを使用してください。
# Check for clock skew $ openclaw debug clock-skew Clock Offset: +0.003s (acceptable) Warning: 2 messages have timestamp conflicts - 部分的ステータス移行:移行が中断された場合、ステータスが混合形式になる可能性があります。移行後に検証してください。
# Force verification $ openclaw state verify --full [✓] Format: v2 [✓] Integrity: VALID [✓] Entries: 1,247 acknowledged, 0 pending, 12 aborted
🔗 関連するエラー
直接的に関連する問題
| 問題 | タイトル | 重要度 | 関連性 |
|---|---|---|---|
| #29125 | Gateway crash silently drops user message from history | P0 | 主要な問題 — 本ガイドで対処 |
| #29126 | Plugin/channel delivery failures are silent and unrecoverable | P0 | 主要な問題 — 本ガイドで対処 |
| #29127 | Abort does not prevent recovery-path re-delivery of partial reply | P0 | 主要な問題 — 本ガイドで対処 |
| #29128 | Delivery-recovery replays already-delivered messages after restart | P0 | 主要な問題 — 本ガイドで対処 |
| #29085 | fix(delivery-queue): Telegram 'bot was kicked' | P2 | 部分的な修正 — #29126の前身 |
歴史的コンテキスト
- #28456 — ネットワークタイムアウト時の重複メッセージ:#29127に似ているが、特にネットワーク起因ではなく中止起因。
- #27901 — 不正なシャットダウン時のステータスファイル破損:#29128との根本原因の重複 — 永続化レイヤーの設計欠陥。
- #27512 — ハンドラACKタイムアウトが過度に攻撃的:#29125に貢献 — タイムアウト設定により早期ACKが許可されていた。
- #27189 — プラグインエラーが親に伝播されない:#29126のアーキテクチャ上の前身 — プラグインシステムにおけるエラー処理の分離。
関連するエラーコード
| エラーコード | 説明 | 関連する問題 |
|---|---|---|
DLV_001 | 永続化書き込み失敗 | #29125 |
DLV_002 | 早期肯定応答 | #29125 |
DLV_003 | サイレント配信失敗 | #29126 |
DLV_004 | リカバリーによる再配信 | #29127, #29128 |
DLV_005 | 起動時のステータス不一致 | #29128 |
PLG_001 | プラグインエラーが飲み込まれる | #29126 |
SHT_001 | グレースフルシャットダウンでのデータ損失 | #29128 |
関連する設定パラメータ
# Parameters introduced/fixed by this guide
delivery.persist_before_ack # Default: true (was: false)
delivery.strict_failure_mode # Default: true (was: false)
delivery.failure_notification_threshold # Default: 3 (new)
recovery.use_explicit_index # Default: true (was: false)
recovery.abort_behavior # Default: preserve (was: re-deliver)
persistence.include_acknowledged # Default: true (was: false)
persistence.backup_on_corruption # Default: true (new)