Friend 后端 Frame-Request 像素留存架构:双桶分层、幂等清理与部署契约校验指南
2026/9/15 18:04:48 网站建设 项目流程

Friend 后端 Frame-Request 像素留存架构:双桶分层、幂等清理与部署契约校验指南

【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend

Frame-request(即时截帧请求)是 Friend 项目中"看屏幕、给建议"能力的核心数据通道,本指南围绕 backend/docs/frame-request-retention.md 展开,系统讲解后端如何用"临时桶 + 永久桶"两级存储管理截帧像素,如何通过调度型 retention worker 幂等收敛清理,以及部署时必须通过的桶契约与独立健康门禁。读完本文,你将掌握这套留存策略的完整生命周期、每一条删除/晋升路径的实现依据,以及可复制的离线校验命令与部署验收清单。

一、为什么需要两套物理存储桶:临时性与永久性并存

Frame-request 队列本身只存元数据(见 backend/database/frame_requests.py 的模块说明:"The queue carries metadata only"),像素字节通过两个物理隔离的 GCS 桶承载,环境变量分别由BUCKET_FRAME_REQUESTS_TEMPORARYBUCKET_FRAME_REQUESTS绑定。两级设计的核心差异如下:

维度临时桶BUCKET_FRAME_REQUESTS_TEMPORARY永久桶BUCKET_FRAME_REQUESTS
生命周期规则6 天Delete规则(delete_age_days: 6无对象过期规则expires_objects: false
软删除关闭,soft_delete_retention_seconds: 0常规策略
承载对象requested / claimed / uploaded 状态的上传像素conversation-attached(已挂接会话)的对象
保存期限未挂接对象最多 6 天跟随会话生命周期,永久保留

源码中对这种分桶的实现非常直白:backend/utils/retrieval/frame_request_storage.py 中_bucket(permanent=...)按布尔值选择环境变量并注释:"The temporary bucket is lifecycle-backed; the permanent bucket must have no object-expiration rule."存储 ID 以temporary-/permanent-前缀区分层级(TEMPORARY_STORAGE_PREFIX/PERMANENT_STORAGE_PREFIX),_is_permanent()对拆分前遗留的无前缀对象回退为永久桶(frame_request_storage.py),保证历史数据兼容。

为什么调度器停机不能依赖桶规则兜底?文档明确:临时桶虽然带 6 天删除规则且关闭软删除,但"调度器停机无法让未挂接像素留存 7 天"。也就是说,桶生命周期规则只是最后一层物理兜底,真正的留存纪律由上层 worker 按 6 天红线主动收敛,二者不能互相替代。从源码看,FRAME_REQUEST_MAX_TTL_SECONDS = 6 * 24 * 60 * 60(见 backend/utils/retrieval/frame_request_policy.py),请求最大 TTL 同样以 6 天为上限,与临时桶删除规则保持一致。

二、挂接晋升(Promotion)的原子流程:先留收据,再复制,后换引用

当一个上传的帧请求被挂接到会话(conversation)时,像素必须从临时桶晋升到永久桶,且整个过程要满足"任何时刻崩溃都可恢复"。文档给出的顺序是:

  1. 先持久化一份永久清理收据(cleanup receipt)——对象尚未被任何会话引用时,收据确保它可被发现、可被清理;
  2. 把临时对象复制到永久桶
  3. 原子地将会话照片(conversation photo)与请求元数据的 storage ID 切换为永久 ID;
  4. 晋升成功后,用可重试的收据移除被替换的临时副本。

源码完整印证了这四步:

  • reserve_frame_promotion_copy()(database/frame_requests.py)在复制之前写入frame_upload_orphans集合下的收据,cleanup_next_attempt_at设为当前时间 +1 小时,"在临时保留期限内留足复制与事务重试时间";
  • copy_frame_request_pixels_to_permanent()(frame_request_storage.py)执行幂等复制,目标 storage ID 必须带permanent-前缀,否则直接抛错;
  • attach_frame_request_to_conversation()(database/frame_requests.py)在单个 Firestore 事务内完成:写入/校验照片子集合文档、更新会话has_content/has_photos标记、把请求行置为attachedcleanup_state = permanentexpires_at = created_at表示不再按时间过期)、并在同一事务中删除晋升收据——注释明确说明:"A failed/ambiguous promotion therefore leaves either a referenced permanent object or a discoverable object due for cleanup"(要么留下已被引用的永久对象,要么留下可被发现、待清理的对象,二选一);
  • 被替换的临时副本由reserve_frame_storage_cleanup()写入kind: "displaced_temporary"收据(database/frame_requests.py),随后由清理 worker 通过收据独立重试删除。

会话内关键约束在模型中也有体现:backend/models/frame_request.py 校验attached状态必须携带conversation_idexpires_at必须等于created_at(即不允许基于时间的过期)、且不能携带 terminal reason;而FRAME_REQUEST_MAX_ATTACHED_PER_CONVERSATION = 1(frame_request_policy.py)保证每个会话最多一张永久关键帧。

三、调度型 retention worker:元数据终态驱动的收敛清理

requested / claimed / uploaded 的对象除了依赖桶生命周期外,还会被调度型 retention worker 主动删除,触发条件是:其 Firestore 行到达终态清理状态cleanup_statependingfailed,且cleanup_next_attempt_at已到期)。这意味着即使没有任何设备再调用 pending 轮询接口,外部像素删除依然会收敛——这是文档强调的"与队列投递解耦"。

worker 的入口与执行链为:

  • 独立 Cloud Run Job:backend/modal/frame_request_retention_job.py(日志与入口均独立,不做内存态假设);
  • 核心维护逻辑:run_frame_request_retention_maintenance()(backend/services/frame_request_retention.py);
  • 每一账号按rows_per_user(默认 32,即FRAME_REQUEST_MAX_BATCH)分页执行六类操作:
    1. prune_expired_conversation_keyframe_jobs——收敛会话关键帧 outbox;
    2. prune_expired_frame_requests——把过期未挂接行置为prunedterminal_reason: "retention_expired",database/frame_requests.py);
    3. cleanup_frame_request_pixels——对终态且cleanup_statepending/failed的行重试 GCS 删除,失败时按min(86400, 2 ** min(attempts, 16))指数退避(database/frame_requests.py);
    4. delete_expired_frame_request_metadata——仅在像素清理收敛后cleanup_statenot_required/deleted)才删除过期元数据行(database/frame_requests.py);
    5. cleanup_expired_frame_vision_outputs——剥离过期的派生描述文本,但保留无内容、不过期的收据,防止崩溃后把清理误判为再次调用视觉提供商的许可(database/frame_requests.py);
    6. cleanup_ambiguous_frame_upload_pixelscleanup_conversation_frame_deletion_outbox——收敛歧义上传收据与会话删除 outbox。

worker 的幂等与并发设计同样值得关注:_acquire_lease/_release_lease/_fenced_store(services/frame_request_retention.py)使用 Firestore 事务维护代际化(generation-fenced)租约,默认租期 20 分钟,防止重叠 worker 回退任一游标;租约过期即可安全恢复崩溃实例。每账号重试队列(retry_accounts子集合)与全局人口扫描各自持有独立游标有限页预算(每轮user_limit默认 1000,其中约 1/4 预留给重试账号),文档特别点明"poison account 无法钉死全局人口扫描"——一个永久失败的 UID 只占用重试预算,不会阻塞新账号扫描。

3.1 有界分页与"整页完成后才推进游标"

_load_user_page()(services/frame_request_retention.py)只在整页处理完成后才推进持久游标,崩溃后的重复工作是安全的,且"advancing only after the page finishes prevents a skipped account"(避免跳号账号)。_drain_due_pages()(第 179-190 行)把每个操作限制在max_pages=8页内,防止单账号无限消费调度时间。

四、JIT 视觉的只读临时端点与"不重复付费"收据

临时端点读取永不晋升、永不续期:一个未挂接会话的已上传请求(uploaded)保持临时性质,仅通过**属主 + 账号代际(owner- and generation-fenced)**的临时图像端点对已认证的 JIT 视觉开放;读取它既不会把对象提升到永久桶,也不会延长其过期时间。这保证了"看过一眼"不改变留存语义。

look_at_frame消费端的两次预留:后端 Agent 的look_at_frameconsumer 只接受该请求中准入的屏幕证据引用(screen evidence reference),并在调用付费视觉之前同时预留:

  • 请求作用域的单帧预算(one-frame budget);
  • 持久、稳定的单轮调用收据(durable stable-turn invocation receipt)。

收据先于提供商出网写入,见reserve_frame_vision_invocation()(database/frame_requests.py):收据 ID 由authority_key的 SHA-256 派生,事务内校验request_idaccount_generation必须匹配,否则抛出PermissionError("vision receipt authority mismatch")预留后若崩溃,返回诚实的"结果不确定(indeterminate)"响应,绝不重复付费——complete_frame_vision_invocation()(第 144-175 行)只写入截断到 4000 字符的有界描述,且output_expires_at遵循FRAME_REQUEST_MAX_TTL_SECONDS(6 天)。

遥测内容纪律:该路径的 telemetry 只包含封闭结果字段与"是否调用视觉"布尔字段,绝不携带帧 ID、OCR 文本、像素或描述内容。源码侧同样贯彻这一点——cleanup_expired_frame_vision_outputs更新时使用firestore.DELETE_FIELD直接删除description/completed_at/output_expires_at,而非置空字符串;删除失败重试元数据也只写last_error_text(截断 2000 字符)这类有界错误信息。

五、桌面端(Mac)关键帧:元数据 outbox 与恢复循环

已完成桌面会话独立于发布可用性持久化一条仅元数据的关键帧 outbox 意图(metadata-only keyframe outbox intent)。其后由三路机制收敛:会话 finalization、后续屏幕同步(later screen sync)、以及每小时的恢复 worker(hourly recovery worker)。

文档规定了选区(selection)的严格条件,源码与配置侧均可验证:

  • 查询限定精确设备、账号代际、会话时间窗口,且最新优先(newest-first);
  • 要求 Mac 提供fail-closed 的 Rewind 排除认证(Rewind-exclusion attestation)——认证失败即拒绝,而非放行;
  • 使用权威本地截图 ID(authoritative local screenshot ID)。

上传解码纪律:上传解码会剥离元数据(EXIF 等),并将 JPEG / PNG / WebP 统一规范化(canonicalize)为有界 JPEG,再进行存储或送视觉。这既压缩体积、规避隐私泄漏,也保证视觉输入格式一致。

恢复循环与降级:既有的桌面恢复循环负责上传并晋升(promote)胜出截图;本地捕获缺失或老化时,会话终态化为 pruned,但不阻塞纯文本会话继续。也就是说,关键帧是增强项,缺失时对话照常可用,只是少了截帧证据。

六、删除边界:outbox 先行、枚举兜底、永久对象永不被临时清理选中

文档在删除路径上给出了三条强保证,均有源码佐证:

  1. 会话删除persist_conversation_frame_deletion_outbox()(database/frame_requests.py)在删除会话元数据之前,先为每个对象持久化一条删除 outbox(收据 ID =sha256(conversation_id \0 storage_id)),随后cleanup_conversation_frame_deletion_outbox()独立重试删除——即使外部存储删除失败,也不会丢失对象引用,始终可恢复。delete_frame_requests_for_conversation()(第 1233-1271 行)以 450/页有界分页删除队列行与关键帧任务,并带硬页栅栏:静默截断会直接抛RuntimeError而非给出"已删除"的假象。

  2. 账号删除delete_all_frame_request_pixels_for_user()(frame_request_storage.py)枚举两个存储层(临时 + 永久),删除后再次列出前缀校验remaining为空,残留即抛错;配套的list_all_frame_request_storage_ids/list_all_frame_upload_orphan_storage_ids/list_all_frame_deletion_outbox_storage_ids(database/frame_requests.py)对队列页、歧义上传收据、删除 outbox、照片子集合做穷举式分页枚举(默认 500/页、上限 1000 页,超限抛错),确保不透明对象在删号时无一遗漏。

  3. 临时清理永不选中永久对象cleanup_frame_request_pixels()的查询显式排除attached状态(TERMINAL_FRAME_REQUEST_STATES - {attached}),delete_expired_frame_request_metadata()的索引查询同样只覆盖非 attached 终态;prune_expired_frame_requests事务内重读行并显式跳过attached,杜绝并发晋升被过期清理覆盖。delete_frame_request_pixels()则通过_is_permanent(storage_id)选择正确的桶,并把NotFound视为安全幂等结果(frame_request_storage.py)。

对象路径的不可寻址性:存储路径为frame-requests/{uid}/{sha256(storage_id)}(frame_request_storage.py),由哈希派生的不透明路径杜绝调用方传入任意路径读写他人对象;写入时还套用owner_storage_write_gate属主写闸门。

七、部署验收:桶契约离线校验 + 线上桶门禁 + 独立健康门禁

文档把部署验收拆成三层,任何一层不过都不应宣称"留存配置正确"。

7.1 契约文件与离线校验

桶契约定义在 backend/deploy/frame-request-bucket-contract.json,关键字段:

{ "permanent_bucket_env_var": "BUCKET_FRAME_REQUESTS", "temporary_bucket_env_var": "BUCKET_FRAME_REQUESTS_TEMPORARY", "conversation_attachment_policy": "conversation_lifetime", "allowed_locations": ["US", "US-CENTRAL1"], "uniform_bucket_level_access": true, "public_access_prevention": "enforced", "encryption_at_rest": "google_managed_or_cmek", "lifecycle": { "expires_objects": false }, "temporary_lifecycle": { "delete_age_days": 6, "soft_delete_retention_seconds": 0 } }

离线(source-only)校验命令(文档原样给出,脚本位于 backend/scripts/validate_frame_request_bucket_contract.py):

python backend/scripts/validate_frame_request_bucket_contract.py \ --source-only \ --runtime-env backend/deploy/runtime_env.yaml \ --contract backend/deploy/frame-request-bucket-contract.json

校验器是刻意离线的(模块 docstring:"This is intentionally an offline validator... It never creates or mutates a bucket"),它做四件事:

  • validate_contract_document():校验契约本身——必须命名两个环境变量、conversation_attachment_policy必须为conversation_lifetime、永久桶expires_objects必须显式为false、位置限定、强制 UBLA、public_access_prevention必须为enforced、临时桶删除年龄在[1,7)天内且软删除关闭;
  • validate_runtime_binding():递归扫描 runtime env 清单(backend/deploy/runtime_env.yaml),要求两个桶变量均被绑定且保留env_var名称,绑定必须携带 default/value;
  • --source-only模式下若同时传入--bucket/--lifecycle-json等实参,会报错--source-only cannot claim live bucket validation——源码模式不能声称验证了线上桶
  • 非 source-only 模式(配合gcloud storage buckets describe --format=json导出的 lifecycle JSON)会逐项比对真实桶名、location、lifecycle 规则(存在age/createdBefore等过期条件即报"expires objects")、UBLA、公共访问防护、加密(Google 托管或指定默认 KMS 键)。

7.2 线上桶门禁

部署必须额外通过工作流的在线gcloud storage buckets describe验证,对两个桶逐一确认:精确桶身份(bucket name 与绑定一致)、location、分层的 lifecycle、软删除、uniform bucket-level access、public-access prevention 与加密姿态。文档明确:仅 runtime-env 校验不能声称通过线上桶门禁

7.3 独立健康门禁:frame-request-retention-hourly

独立的frame-request-retention-hourlyCloud Scheduler 目标同样是部署门禁,且不共享canonical 的 memory-maintenance 调度。关键回滚安全机制:在操作者观察到该 job 健康并设置FRAME_REQUEST_RETENTION_INDEPENDENT_HEALTHY=true之前,memory maintenance 会保留旧版清理调用作为回滚安全桥。源码印证:backend/modal/memory_maintenance_job.py 中,当FRAME_REQUEST_RETENTION_INDEPENDENT_HEALTHYtrue时,仍以user_limit=250调用run_frame_request_retention_maintenance()作为 legacy 安全兜底,且其异常只记日志、不影响 canonical maintenance 继续。runtime env 中该变量的绑定(见 backend/deploy/runtime_env.yaml)与BUCKET_FRAME_REQUESTS*绑定同时出现在保留 job 的服务定义中。

7.4 设备身份边界

文档特别澄清:桌面端device_id属主作用域的路由与恢复标识符,而非独立凭据;Firebase 认证、账号代际与精确设备匹配仍是权威判定,当前产品不主张加密级设备认证,加强该边界属于独立的后续安全决策。

八、用户导出:显式的bytes_available语义

用户导出包含三类内容:

  • frame-request 元数据;
  • 持久化的视觉 / 关键帧收据(durable vision/keyframe receipts);
  • 显式的会话照片清单(explicit conversation-photo manifests)。

清单的可用性语义是显式的:当专属对象可读时,清单携带bytes_base64字段;当对象不可用时,以bytes_available: false显式表达"该图不可导出",而不是静默省略图片。这避免了客户端把缺失误读为"导出成功但无图",保证隐私与数据完整性语义对用户透明。

九、全生命周期速查与关键文件索引

关注点关键实现
双桶选择与对象路径backend/utils/retrieval/frame_request_storage.py
队列状态机与清理状态backend/models/frame_request.py
挂接晋升、outbox、各清理操作backend/database/frame_requests.py
调度 worker 主循环与租约backend/services/frame_request_retention.py
独立 job 入口backend/modal/frame_request_retention_job.py
策略常量(TTL/配额/批大小)backend/utils/retrieval/frame_request_policy.py
桶契约与离线校验器backend/deploy/frame-request-bucket-contract.json、backend/scripts/validate_frame_request_bucket_contract.py
runtime env 桶绑定backend/deploy/runtime_env.yaml
双桶行为测试backend/tests/unit/test_frame_request_storage_tiers.py
worker 分页与独立 job 测试backend/tests/unit/test_frame_request_retention_pagination.py、backend/tests/unit/test_frame_request_retention_job.py

从整体设计看,这套留存体系的核心可概括为三句话:临时与永久物理隔离,晋升靠"先收据后复制再原子换引用"保证任何时刻可恢复;删除靠"outbox/收据先行 + 调度 worker 指数退避重试"保证外部存储失败不丢引用;部署靠"契约文件 + 离线校验 + 线上桶门禁 + 独立健康门禁"四层验收保证留存语义不漂移。对需要落地类似"会话级永久图片 + 未挂接对象限期回收"留存策略的团队,这份文档与其配套源码是一份可直接复用的参考实现。

【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询