灯光模拟HarmonyOS应用实战-69-onBackup已返回,最后一次历史写入还在flush:用BackupWriteBarrier建立一致性点

用户刚完成最后一道灯光题,结果页已经出现,系统紧接着触发应用备份。备份回调很快返回“完成”,而历史记录的 Preferences.flush() 仍在另一条 Promise 链上等待。恢复后少了最新一条记录,页面和日志却都没有明确失败。

这是生命周期与业务写入没有共同一致性点的问题。The_kemusan 当前历史写入会执行 put()flush(),但页面采用 .then() 异步接收结果;EntryBackupAbility.onBackup() 只打印日志并等待一个已经完成的 Promise。两条链彼此不知道对方的进度。要让“备份开始前已接受的写入”有明确边界,需要用 BackupWriteBarrier 记录写入序号、耐久回执和备份截点。

备份写入屏障封面

这次设计关注的不是延长等待时间,而是回答三个可核对的问题:备份要等哪些写入、怎样知道它们已结束、超时或失败时如何报告而不假装成功。

一、当前onBackup没有连接任何业务存储任务

EntryBackupAbility.ets 的回调只有两步:写一条 hilog,然后 await Promise.resolve()。它没有取得 PracticeStore,没有读取 exam_history,也没有等待页面发起的写入。

export default class EntryBackupAbility extends BackupExtensionAbility {
  async onBackup() {
    hilog.info(DOMAIN, 'testTag', 'onBackup ok');
    await Promise.resolve();
  }
}

项目的 backup_config.jsonallowToBackupRestore 设为 truemodule.json5 也注册了类型为 backupEntryBackupAbility。这些配置说明备份扩展入口存在,不说明业务写入已经和回调形成顺序关系。

层次当前行为尚缺的证据
页面结果调用 addSimpleRecord()写入何时完成
PracticeStoreput()flush()失败是否上抛
备份扩展打日志后立即返回截点前写入是否耐久
恢复扩展记录 BundleVersion恢复内容是否包含末条历史

因此,看到 onBackup ok 只能说明回调走到该日志,不能说明最后一次历史已被持久化或已进入系统备份内容。

二、最后一次答题写入是一条未被备份观察的Promise链

写入与备份竞态流程

页面的 addSimpleRecord() 构造 PracticeRecord 后调用:

this.store.addRecord(record).then((records: PracticeRecord[]) => {
  this.records = records;
});

这个方法本身返回 void。调用它的 answerTheoryQuestion()finishLightExam()finishPracticalExam() 都不会等待历史写入。存储层内部确实有顺序:先读取旧记录,插入新记录,截取最多 100 条,再执行 putString()putString() 中依次等待 put()flush()。但这条内部顺序没有暴露给页面或备份层。

还有一个更隐蔽的边界:putString() 捕获异常后直接返回,addRecord() 仍可能把 nextRecords 当作返回结果。于是 UI 可以更新出一条仅存在内存的历史,而调用方拿不到“flush 失败”的回执。

竞态可以按时间排列:

  1. T1:页面接受最后一题结果并调用 addRecord()
  2. T2Preferences.put() 已调用,flush() 尚未结束;
  3. T3:系统调用 onBackup()
  4. T4onBackup() 等待已完成 Promise 后返回;
  5. T5:业务写入才完成或失败。

固定睡眠 500 毫秒无法解决这个问题。设备负载和存储时延不是常量,而且睡眠结束时仍不知道写入结果。

三、先定义“备份截点前写入”的语义

屏障需要一个清楚的承诺:当备份回调取得截点序号 cutoffSequence 后,等待所有序号不大于该值的已接受写入进入终态。截点之后新产生的记录可以留给下一次备份,不能让不断到来的写入使本次等待永不结束。

export enum DurableWriteState {
  ACCEPTED = 'accepted',
  DURABLE = 'durable',
  FAILED = 'failed'
}

export interface WriteReceipt {
  sequence: number;
  state: DurableWriteState;
  errorCode: string;
}

export interface BackupCutoff {
  sequence: number;
  capturedAt: number;
}

sequence 由写入协调器单调分配。ACCEPTED 表示任务进入队列,不能等同于已经刷入存储;DURABLE 表示项目定义的持久化步骤已成功返回;FAILED 保留稳定错误码。究竟 Preferences.flush() 的完成与系统备份快照之间有哪些平台保证,还要结合目标 SDK 文档和故障实验确认,业务层不能自己扩大承诺。

四、BackupWriteBarrier集中跟踪未完成写入

页面、写入协调器与备份扩展结构

屏障应该靠近唯一写入协调器,而不是让每个页面保存自己的 Promise。下面的示例展示核心状态:分配序号、登记任务、记录终态、按截点等待。

export class BackupWriteBarrier {
  private nextSequence: number = 1;
  private receipts: Map<number, WriteReceipt> =
    new Map<number, WriteReceipt>();
  private pending: Map<number, Promise<WriteReceipt>> =
    new Map<number, Promise<WriteReceipt>>();

  accept(task: () => Promise<void>): WriteReceipt {
    const sequence: number = this.nextSequence++;
    const accepted: WriteReceipt = {
      sequence,
      state: DurableWriteState.ACCEPTED,
      errorCode: ''
    };
    this.receipts.set(sequence, accepted);
    const completion: Promise<WriteReceipt> =
      this.runTask(sequence, task);
    this.pending.set(sequence, completion);
    return accepted;
  }

  captureCutoff(): BackupCutoff {
    return {
      sequence: this.nextSequence - 1,
      capturedAt: Date.now()
    };
  }
}

accept() 只表示排队成功。实际的 runTask() 应把异常转换为 FAILED 回执并清理 pending。如果多个答题事件允许并发写同一个 JSON 数组,还要在协调器里串行化“读—改—写”,否则两次写入可能都读取同一个旧数组,后完成的一次覆盖前一次。

五、写入端口必须返回耐久回执而不是吞掉异常

屏障无法从一个永远正常返回的 putString() 判断失败。存储端口需要把阶段和错误传给协调器,页面再决定如何提示或重试。

interface HistoryWritePort {
  writeHistoryJson(value: string): Promise<void>;
}

class PreferencesHistoryWritePort implements HistoryWritePort {
  constructor(private store: preferences.Preferences) {}

  async writeHistoryJson(value: string): Promise<void> {
    await this.store.put('exam_history', value);
    await this.store.flush();
  }
}

async function persistHistory(port: HistoryWritePort,
  records: PracticeRecord[]): Promise<void> {
  const payload: string = JSON.stringify(records);
  await port.writeHistoryJson(payload);
}

这里刻意不捕获后静默返回。端口只负责原样报告失败,协调器再生成业务错误码。日志不应写完整历史 JSON;序号、载荷长度、摘要和错误码足够定位,多余的答题正文会扩大隐私暴露面。

如果页面需要乐观更新,也要保留 pending 标记。写入失败时展示“记录未保存”并允许重新提交,不能继续把它计入已持久化统计。

六、备份回调等待固定截点并返回明确结果

waitUntilSettled() 只等待截点内任务。全部进入 DURABLE 才返回成功;任一失败、超时或协调器不可达都产生不同状态。

export enum BarrierResultCode {
  READY = 'ready',
  WRITE_FAILED = 'write_failed',
  TIMEOUT = 'timeout',
  COORDINATOR_UNAVAILABLE = 'coordinator_unavailable'
}

export interface BarrierResult {
  code: BarrierResultCode;
  cutoffSequence: number;
  failedSequences: number[];
}

async function prepareBusinessDataForBackup(
  barrier: BackupWriteBarrier
): Promise<BarrierResult> {
  const cutoff: BackupCutoff = barrier.captureCutoff();
  return await barrier.waitUntilSettled(cutoff, 1500);
}

1500 毫秒只是示例策略,不是 HarmonyOS 固定要求。真实超时要根据数据量、回调约束和设备实验确定。超时后不能打印 onBackup ok;应记录截点与结果码,并按平台允许的方式让此次备份失败或延后。

备份扩展与页面是否位于同一进程、是否能访问同一个内存单例,必须按实际配置验证。如果不能共享,屏障状态需要落到一个双方都能读取的耐久协调介质,或把所有历史写入统一交给可达服务。仅在两个类里各自 new BackupWriteBarrier() 不会形成屏障。

七、进程重建时需要耐久序号和回读证明

纯内存 pending 能协调同进程并发,却无法解释进程在 put()flush() 之间终止后的状态。更强的方案会为正式载荷保存提交序号,并在写入后回读确认序号与摘要。

interface DurableHistoryEnvelope {
  schemaVersion: number;
  commitSequence: number;
  records: PracticeRecord[];
  payloadDigest: string;
}

interface DurableMarker {
  commitSequence: number;
  payloadDigest: string;
}

业务流程可以是“生成信封 → 写入 → flush → 回读 → 比较序号与摘要 → 标记该序号为 DURABLE”。摘要用于一致性比对,不能自动等同于加密或隐私保护。是否采用单 key、候选 key 与正式 key,取决于 Preferences 的实际能力和故障模型;没有平台事务保证时,不应把多 key 顺序描述成原子提交。

恢复后还可以比较信封和耐久标记。如果二者不一致,页面进入“历史待恢复”状态,不把载荷直接当正常记录,也不自动清空。

八、并发写入必须先串行化再等待屏障

假设两道题很快连续写入:A 和 B 都先调用 listRecords(),都读取到旧数组 R0。A 写 A + R0,B 写 B + R0,最终哪个后完成就覆盖另一个。即使屏障等到两次 flush 都结束,数据仍可能缺一条。

class HistoryWriteQueue {
  private tail: Promise<void> = Promise.resolve();

  enqueue(operation: () => Promise<void>): Promise<void> {
    const next: Promise<void> = this.tail.then(operation);
    this.tail = next.catch(() => {
      return;
    });
    return next;
  }
}

每次“读—改—写—回读”作为一个队列任务,再交给屏障登记。tail 上的 catch 只用于让后续任务继续排队,原任务的 next 仍向调用方报告异常。否则第一次失败会让队列永久拒绝,也可能再次吞掉错误。

风险仅等待所有 Promise串行写入 + 屏障
备份早于 flush 返回可避免可避免
两次读改写互相覆盖不能避免可以按序处理
写入失败被记录取决于端口明确回执
截点后新写入拖住本次备份可能固定截点后不会

九、故障用例要控制Promise完成顺序

正常设备上连续点击几次很难稳定复现竞态。测试端口应允许暂停 putflush 和回读,主动构造不同顺序。

用例安排关键断言
B01无待写入时触发备份截点 0,立即 READY
B02截点内一条 flush 延迟回调在该任务终态前不返回 READY
B03截点后新增一条本次只等待旧截点
B04一条写入抛错返回 WRITE_FAILED 和对应序号
B05flush 永不结束到策略时间返回 TIMEOUT
B06两次写入同时发起队列后载荷包含两条记录
B07写后回读摘要不一致不标记 DURABLE
B08进程重建后标记不一致恢复入口报告待处理状态

还要核对日志顺序:先出现写入接受序号,再出现备份截点,最后才是各序号终态与备份结果。只有日志文本而没有序号,无法证明两条异步链的先后关系。

十、验证清单、排障表与事实边界

  • 所有历史写入都经过唯一协调器分配序号。
  • “接受写入”和“耐久完成”使用不同状态。
  • 存储异常不会在端口内部静默变成成功。
  • 同一 JSON 数组的读改写被串行化。
  • 备份开始时捕获固定截点。
  • 截点之后的新任务不会拖住当前备份。
  • 失败、超时和协调器不可达分别报告。
  • 写入后回读并核对提交序号与摘要。
  • 页面能区分内存乐观记录与已持久化记录。
  • 日志不包含完整答题历史正文。
  • 进程和扩展运行边界经过目标环境验证。
  • 备份恢复后核对最后提交序号。
现象先看哪里常见原因修复方向
恢复后少最后一条截点与耐久序号回调未等待业务写入onBackup 前等固定截点
UI有记录、重开后消失写入回执异常被 putString() 吞掉返回失败并标记待保存
连续答题少中间一条写队列并发读改写覆盖串行化完整操作
备份一直不结束截点范围不断等待新任务截点后写入留待下次
打印成功但载荷不一致成功日志位置日志早于屏障结果只在 READY 后记录完成
两个屏障互不相见实例和进程页面与扩展各建单例建立可共享协调入口
flush返回后回读不同提交验证只等待调用完成比较序号与摘要

当前源码能够确认:PracticeStore.putString() 会等待 Preferences.put()flush(),但捕获异常后直接返回;页面的 addSimpleRecord() 返回 void 并通过 .then() 更新内存列表;EntryBackupAbility.onBackup() 没有连接 PracticeStore,只记录日志并等待 Promise.resolve()。因此当前代码没有可见的业务写入屏障。

BackupWriteBarrier、写入序号、串行队列、耐久信封、超时策略和回读协议都是建议方案,尚未写入 The_kemusan。这次没有触发真实系统备份,没有注入 flush 故障,没有运行构建,没有生成新的 HAP,也没有在模拟器或真机完成备份恢复。flush() 与系统备份快照的具体保证、扩展进程边界及回调失败方式,都必须结合对应 SDK 文档、编译结果和设备实验再确认。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐