Skip to content

fix: preserve offline synchronization invariants - #53

Merged
rdlabo merged 1 commit into
mainfrom
agent/fix-offline-sync-invariants
Aug 10, 2026
Merged

fix: preserve offline synchronization invariants#53
rdlabo merged 1 commit into
mainfrom
agent/fix-offline-sync-invariants

Conversation

@rdlabo

@rdlabo rdlabo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

  • make ACK reconciliation durable and restore it across process/session restart
  • serialize transport claim, discard, ACK, and replica mutations through one coordinator
  • distinguish pre-transport failures from durable server-commit ambiguity
  • preserve ambiguity monotonically and recover with the same idempotency key
  • migrate legacy Web/SQLite commands conservatively

Why

Offline commands could previously lose their required post-ACK pull after restart, race with discard while transport was active, or become permanently stuck after a response-loss attempt was later classified as conflict/rejected. Those paths could leave local history stale or make logout/discard unsafe.

Impact

The Outbox now treats replica state and reconciliation markers as one durable transaction. Commands whose server outcome is unknown cannot be discarded and remain recoverable through an explicit same-key retry. Known pre-transport failures remain discardable.

Root cause

Correctness state was split between durable commands and in-memory transport bookkeeping. Failure classification also replaced, rather than accumulated, uncertainty from earlier attempts.

Checks

  • npm test -- --project kit (43 files, 695 tests)
  • focused offline repository/sync tests (3 files, 198 tests)
  • npx ng lint kit
  • npx ng build kit --configuration=production
  • Prettier check on all changed files
  • git diff --check
  • manager review: approved
  • independent acceptance review: approved

@netlify

netlify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Deploy Preview for rdlabo-ionic-angular-library ready!

Name Link
🔨 Latest commit d901c5b
🔍 Latest deploy log https://app.netlify.com/projects/rdlabo-ionic-angular-library/deploys/6a7a55f7f9acbb00099b1bf2
😎 Deploy Preview https://deploy-preview-53--rdlabo-ionic-angular-library.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@rdlabo
rdlabo marked this pull request as ready for review August 10, 2026 22:51
@rdlabo
rdlabo merged commit ff0a39e into main Aug 10, 2026
12 checks passed
@rdlabo
rdlabo deleted the agent/fix-offline-sync-invariants branch August 10, 2026 22:53

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review


#beginFlush(explicitFull: boolean): Promise<void> {
const isPartial = !explicitFull && this.#foregroundScopePolicy !== null;
const isPartial = !explicitFull && !this.#coldReconciliationRequired && this.#foregroundScopePolicy !== null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 起動直後に必須の全体同期が行われず、明示的な全体同期要求も部分同期で置き換えられる

起動直後に全体同期が必要な状態でも、実際の同期処理(#runFlushisPartial 判定 projects/kit/offline/src/lib/offline-sync.service.ts:622)はその必要性を見ずに前面スコープだけを取得したうえで必要フラグを消すため、取り残された更新の取り込みが永久に行われません。
Impact: 前回終了時に取り込めなかったサーバー側の変更が復元されず、画面に古いデータが残り続けることがあります。

#beginFlush と #runFlush で isPartial の判定条件が食い違う仕組み

#coldReconciliationRequired は repository が getReconciliationScopes を実装していない場合に true になり(projects/kit/offline/src/lib/offline-sync.service.ts:142)、#beginFlush ではこの値を見て isPartial=false(=全体flush扱い)と記録します(projects/kit/offline/src/lib/offline-sync.service.ts:577)。しかし実処理の #runFlush では const isPartial = !explicitFull && this.#foregroundScopePolicy !== null; としか判定しないため(projects/kit/offline/src/lib/offline-sync.service.ts:622)、実際には部分pullしか行われません。さらに末尾で this.#coldReconciliationRequired = false; が実行されるので(projects/kit/offline/src/lib/offline-sync.service.ts:676)、以後も全体pullは発生しません。

加えて #partialFlushInFlight が false のまま記録されるため、その最中に呼ばれた明示的な flush()(全体)は実行中の部分flushのPromiseをそのまま返し(projects/kit/offline/src/lib/offline-sync.service.ts:578-599)、全体pullへチェーンされません。

Prompt for agents
#coldReconciliationRequired は「durableな再同期マーカーを復元できないrepositoryでは最低1回は全スコープpullが必要」を表す意図のフラグですが、実際にpull対象を決める #runFlush の isPartial 判定にはこのフラグが含まれていません。その結果、フラグが true でも部分pullが行われ、その直後にフラグがクリアされます。また #beginFlush 側だけがフラグを見ているため #partialFlushInFlight が実態(部分)と食い違い、実行中に呼ばれた明示的な全体 flush() が部分flushの完了で満たされてしまいます。#runFlush 側の判定にも同じ条件を反映し、フラグのクリアは実際に全体pullを完了した場合のみ行うよう整合させてください。
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +561 to 566
#assertDiscardable(commands: readonly OfflineCommand[]): void {
const ambiguous = commands.filter((command) => command.state === 'sending' || command.serverCommitUnknown === true);
if (ambiguous.length > 0) {
throw new OfflineCommandInFlightError(ambiguous.map((command) => command.commandId));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 同期結果が不明なコマンドがあるとログアウト時の「破棄」操作が例外で失敗する

ログアウト時の破棄処理は、サーバー結果が不明なコマンドがあると例外を投げるようになった破棄APIを(#assertDiscardable at projects/kit/offline/src/lib/offline-sync.service.ts:561-566)例外処理なしで呼び続けるため、ログアウト操作自体がエラーで中断します。
Impact: 送信結果が確定していない変更が1件でも残っていると、ユーザーは「破棄してログアウト」を選んでもエラーになり操作が完了しません。

呼び出し側が新しい例外を扱っていない

OfflineCoordinatorService.prepareLogoutaction === 'discard' のとき await this.#sync.discardAllPending(); return true; としており(projects/kit/offline/src/lib/offline-coordinator.service.ts:90-98)、false を返して「ログアウトできない」ことを伝える経路はあるものの、新設の OfflineCommandInFlightErrorprojects/kit/offline/src/lib/offline-sync.service.ts:100-106)は捕捉されずそのまま呼び出し元へ伝播します。また discardAllPending は1件でも曖昧なコマンドがあると全件の破棄を拒否するため、他の破棄可能なコマンドも削除されません。

Prompt for agents
discard()/discardAllPending() が新たに OfflineCommandInFlightError を投げるようになりましたが、リポジトリ内唯一の利用者である OfflineCoordinatorService.prepareLogout('discard') はこの例外を扱っておらず、boolean を返す契約が破れます。prepareLogout 側で例外を捕捉して false を返す(あるいは曖昧なコマンドの存在を呼び出し元へ伝える)等、ログアウトUIが破綻しない扱いを追加してください。
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1344 to +1349
async #markScopeReconciled(scope: OfflineScope, generation: number): Promise<void> {
if (!this.#isCurrent(generation)) return;
await this.#repository.transactReplica({ removeReconciliationScopes: [scope] });
if (!this.#isCurrent(generation)) return;
this.#pendingPullScopes.delete(this.#scopeKey(scope));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 取得処理のたびにローカルデータ全体が無条件で書き戻され、書き込み量が倍増する

スコープ取得のたびに再同期マーカー削除のための永続トランザクションを無条件に実行するため(#markScopeReconciled の呼び出し projects/kit/offline/src/lib/offline-sync.service.ts:630)、マーカーが存在しない通常の取得でもローカル保存データ全体の再書き込みが発生します。
Impact: 同期のたびに端末ストレージへの書き込み量が増え、データ量の多い利用者で同期が遅くなります。

Web実装での書き込みコスト

#markScopeReconciled は対象スコープが #pendingPullScopes に含まれるかを確認せずに repository.transactReplica({ removeReconciliationScopes: [scope] }) を実行します(projects/kit/offline/src/lib/offline-sync.service.ts:1344-1349)。IonicOfflineRepository.#applyReplicaTransaction は変更対象が何であっても ROWS/OUTBOX/CURSORS/RECONCILIATION の4レコードを読み直して全件書き戻し、さらにジャーナルの set/remove も行います(projects/kit/offline/src/lib/offline-repository.ts:628-698)。pull自体でも同様の全件書き込みが起きるため、pullごとの書き込みが実質的に倍になります。#runFlush の事前pullループは既知スコープすべてに対してこれを実行します。

Suggested change
async #markScopeReconciled(scope: OfflineScope, generation: number): Promise<void> {
if (!this.#isCurrent(generation)) return;
await this.#repository.transactReplica({ removeReconciliationScopes: [scope] });
if (!this.#isCurrent(generation)) return;
this.#pendingPullScopes.delete(this.#scopeKey(scope));
}
async #markScopeReconciled(scope: OfflineScope, generation: number): Promise<void> {
if (!this.#isCurrent(generation)) return;
if (!this.#pendingPullScopes.has(this.#scopeKey(scope))) return;
await this.#repository.transactReplica({ removeReconciliationScopes: [scope] });
if (!this.#isCurrent(generation)) return;
this.#pendingPullScopes.delete(this.#scopeKey(scope));
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return cursor === undefined ? null : { ...scope, cursor };
}

async getReconciliationScopes(userId: OfflinePrincipalId): Promise<OfflineScope[]> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 新規に追加した公開メソッドにJSDocコメントがない

新設した公開メソッド getReconciliationScopesprojects/kit/offline/src/lib/offline-repository.ts:355)にJSDocコメントが付いておらず、AGENTS.mdの「すべての公開クラス・関数・型にJSDocコメントを付ける」という必須ルールに違反しています。
Impact: 公開APIの説明が欠け、リポジトリの記述規約に反した状態で公開されます。

該当箇所

同じ違反が SqliteOfflineRepository.getReconciliationScopesprojects/kit/offline/src/lib/sqlite-offline-repository.ts:325-330)と OfflineRepository インターフェースの getReconciliationScopes?projects/kit/offline/src/lib/offline-repository.ts:182)にもあります。AGENTS.md「When modifying this repo」3項を参照。

Suggested change
async getReconciliationScopes(userId: OfflinePrincipalId): Promise<OfflineScope[]> {
/** Returns scopes whose acknowledged server changes still require an authoritative pull. */
async getReconciliationScopes(userId: OfflinePrincipalId): Promise<OfflineScope[]> {
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant