Skip to content

security/explain 批量形态对每个 recordId 串行重跑一次完整 explain:50 行列表页的两次调用服务端各 3 s,排在页面最后一层并占住内核事件循环 #29112

Closed

Description

@objectstack-fleet

coordinator#1 · hotlong · session_6eb9ec2e0e3f410b91239dab7a337b9e

立卡门类别:① 已复现缺陷(reach: 公开入口实测,staging 客户列表页,见「已实测」)

Reader: domain:server 车道。packages/rest/src/security-routes.ts(批量循环)与 packages/plugins/plugin-security/src/security-plugin-explain-access.ts / explain-engine.ts(对象级判定只算一次、记录级按 id;或用一次带 id in (...) 的写过滤查询回答每条记录)。ui/packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts 只作备选(菜单打开时再问)。建议档 p2,由分诊定。

Dedupe: repo:objectstack-ai/objectstack is:issue security/explain slow total=23(无同题,最近 #26754 closed 是测试时长);explain batch recordIds per id loop total=6(#22571、#22792 closed,语义卡);explain latency record visible total=40(无同题)。最相关的已关卡:#8326(批量形态的来源)、#27626(客户端去重;其正文说明 explain ×2 是 update / delete 两个问题)。

业务理由:每个打开 HotCRM 列表页的用户,行级「编辑 / 删除」菜单要等这两次判定(约 3–4 s)才有结论;托管运行时一个容器同时跑多个环境的内核,这 2×3 s 的 CPU 让同容器其他租户的请求排在后面。不修,列表页永远以 explain 收尾,环境越多越慢。

来源

2026-11-02 staging 页面性能评估(cloud main cd4e2e1f,objectstack pin 6cbdef1e;控制面 cloud.objectos.app,租户 os-2ph4c1.objectos.app / HotCRM 6.0.0;内置浏览器面板 zh-CN,读 resource timing 与 v2.7.0 起的 Server-Timing: runtime/placement/origin,辅以 Workers Observability)。维护者在会话里说「评估一下核心页面新版的性能有改进吗,还能继续优化吗」「相关性能优化使用 fable 评估后立卡」;本卡经第二个审核会话按 origin/main 核对落点与查重。
⚠️ 本机链路当天很差(CF 边缘静态文件 65 KB/s),卡里的秒数只引用 Server-Timing 或请求的先后关系,不引用页面总耗时。

已实测

  • 客户列表页(/apps/app.objectstack.hotcrm/crm_account,31 行)resource timing:两个 POST /api/v1/security/explain 在 6246 ms 才发出(前一层结束之后),各自 Server-Timing 为 runtime=3396 origin=3473 与 runtime=2986 origin=3062;同页其他接口 runtime 在 50–920 ms。
  • Workers:新版窗口 /api/v1/security/explain p50 3257 / p90 3661 ms(n=12,每页 2 次)。旧版(v2.6.0)窗口 p90 3568,说明批量在旧 pin 就是这个代价,不是两 pin 间的回退(旧窗口的 p50 较低,是因为当时记录页还单独发便宜的单条 explain,样本构成不同)。

落点

  • 调用方:ui/packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts:364-440,每页按 (object, operation) 各发一个批量请求,请求体 { object, operation: 'update'|'delete', recordIds: [整页行 id] }(上限 200)。两次 = update + delete。
  • 服务端:packages/rest/src/security-routes.ts:256-281 先做一次对象级 svc.explain(singularRequest),再 for (const id of new Set(recordIds)) { await svc.explain({ ...singularRequest, recordId: id }) }:逐 id 串行跑完整的单条判定(每次重新解析权限集、对象安全元数据、RLS、共享规则、写探针、两次按 id 读行)。注释写明这是「单条判定的传输合并,不是新语义」,批量 ≡ N 次单条由测试钉住。
  • 记录页已经不单独发 explain:feat(rest,console): one request opens a record page #27713 把 update / delete / transfer 判定装进 POST /data/:object/:id/page;但服务端 record-page-routes.ts:393-405 对这几个判定也是串行 await、各自重算对象级部分,修批量时顺手让它们共享对象级判定并并行,不另开卡。

验收

  1. 50 个 id 的批量请求:本地隔离 CPU ≤ 单条的 3 倍(或每 id 边际 ≤ 10 ms);staging Server-Timing runtime ≤ 500 ms。
  2. 既有「批量 ≡ N 次单条」一致性测试保持通过。
  3. staging 客户列表页瀑布里 explain 不再是最后一层的长尾。

相关

cloud#2914(控制台打开的串行瀑布,父单)、objectstack#8326、objectstack#27626、objectstack#27713

🤖 Generated with Claude Code

Activity

  1. added
    bugSomething isn't working
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 2, 2026
  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: p2 (priority added) · 2026-11-02T08:21Z

    ⛔ Not a claim.

    • Read fresh. The batch form of security/explain re-reads per record (N+1). A performance defect on a named server path: p2, domain:server as filed. Routing and the pm state the filer set are unchanged.

    Generated by Claude Code

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

    Claim: PM loop round 1 · card thread · 2026-11-02T08:30Z
    Session: session_01ADHkmQxwoGb5fSnrFkpxmt (the seat); the card thread is session_01ADHkmNqE95va9aNi7nYajP
    Account: os-steve (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-3fwdg3 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    Ruling-ref: the maintainer, verbatim: 「开 server 2、3、5 号席位, 前任已经下班,接手所有开发,包括跟进之前未完成的开发落地。」 Card: none from the maintainer on this card; triage graded p2 (6572449390).
    File surface: the batch loop in packages/rest/src/security-routes.ts; packages/plugins/plugin-security/src/security-plugin-explain-access.ts / explain-engine.ts (object-level part computed once); record-page-routes.ts only if the same change covers it; tests; changeset. ⛔ No ui/**.
    Container & model: M, mode:thread, model: opus (TIER_DEFAULT; --tier names no path mandate: PM's call), effort: high
    Clause-②: no (expected: same answers, faster; batch ≡ N singles stays pinned)
    Metadata-protocol: none (expected)
    Responsibility: the code is the batch form of POST /security/explain in @objectstack/rest and the explain engine of @objectstack/plugin-security · the platform path is a list page whose two batch explains re-run a full single explain per row (≈3 s each) · reached by every list page with row edit/delete menus
    Thread-read: 6572449390
    Premise: holds (the named seam read on origin/main by the seat this act; the dev re-measures red first)
    Serial constraints cleared: none — no open claim on a domain:server pm:dispatched card names these files (read this act)
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-steve, recorded by this claim.

  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-steve · session_01ADHkmNqE95va9aNi7nYajP
    os-dev-report

    {
      "issue": 29112,
      "status": "done",
      "branch": "claude/project-thread-3fwdg3",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29208",
      "session": "session_01ADHkmNqE95va9aNi7nYajP",
      "premise_still_valid": true,
      "summary": "The batch form of security/explain ran a whole singular explanation per id. The explain engine is split (no change in what it computes) into an object-level pass, a record pass and a decision builder; a new explainRecordsForAsker runs the object-level pass once and the record pass per distinct id. The security service honours request.recordIds, settling the caller admission and explained context once; the REST door hands the batch over in one call and keeps the per-id path for a service that predates it. Local rig, 50 ids, update: queries 505 -> 231, CPU 284 -> 147 ms; per-id margin about 2.9 ms (card bound 10 ms met); the 3x-single arm is not met (about 4.5 per-record queries per id remain). record-page-routes.ts unchanged (three different operations share no object pass).",
      "tests": "head fc99ac4f. plugin-security test: 239 files, 5171 passed/45 skipped; rest test: 367 files, 6966 passed/332 skipped; both typecheck green (rest after test-layer fix fc99ac4f). New explain-batch-parity.test.ts 44/44 (40 AGREEMENT cases: 4 askers x 5 verbs x 2 objects, mixed set incl. shared, unshared, RLS-locked, missing, duplicate ids, when-conditioned field entry, unit depth w/o hierarchy resolver; 2 one-object-pass read-count pins). Red first on main sources: 42 failed/2 passed (the 2 read-count pins pass vacuously there). REST suites on main source: 4 failed/48. Ablation 1 (engine: object pass per record): read-count pins red 2/2, findOne sys_user 1->6 and 2->6, restored blob==HEAD. Ablation 2 (REST ignores batch answer): 2/25 red, restored. Ablations ran against src (tests import src directly; no dist path).",
      "gates": "dispatch-gates --commands on fc99ac4f: 65; --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. 62 exit 0 first pass; check-plugin-teardown-shape --self-test exit 3 (shallow fixture) -> exit 0 after fetching the pinned commit; check:i18n exit 3 (CLI unbuilt) -> exit 0 after its named closure build; check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (whole-workspace dist) = NOT MEASURED locally, CI Lint and Repo Gates runs it. eslint --no-inline-config on the 6 touched files exit 0. PR check-runs: in_progress at report time.",
      "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "3 — mcp__github__issue_read x2 (card comments), mcp__github__pull_request_read x1 (PR read-back); no write tools",
      "api_writes": "2 — relay pr_create (PR #29208, 11114 bytes sent = stored), post-stamped os-dev-report comment on #29112",
      "line_budget": "7 files, +682 / -113 (under the 3000 human-merge threshold)",
      "files_changed": [
        ".changeset/29112-batch-explain-one-object-pass.md",
        "packages/plugins/plugin-security/src/explain-batch-parity.test.ts",
        "packages/plugins/plugin-security/src/explain-engine.ts",
        "packages/plugins/plugin-security/src/security-plugin-explain-access.ts",
        "packages/rest/src/security-explain-envelope.test.ts",
        "packages/rest/src/security-routes.test.ts",
        "packages/rest/src/security-routes.ts"
      ],
      "deviations": [
        "PR body: the harness attribution block takes lines 1-2, so Fixes #29112 is line 3 (os-dev allows the harness attribution ahead of Fixes)",
        "Forced test edits named in the PR body: security-routes.test.ts first call now carries recordIds; security-explain-envelope.test.ts batch row's asked request carries recordIds",
        "check:dual-build-cjs-loads NOT MEASURED locally (needs whole-workspace build); declared to CI",
        "test:repo not run locally (diff touches no repo-project file)"
      ],
      "open_questions": [
        {
          "question": "The service-level batch rides request.recordIds, which the wire schema (ExplainRequestSchema) and ExplainDecisionSchema.records already declare but the TS contract ExplainAccessRequest (packages/spec/src/contracts/security-service.ts) does not. Declare it there?",
          "options": [
            "A: follow-up spec card adds optional recordIds to ExplainAccessRequest (no new key name; the wire key already exists), Clause-2 stays no",
            "B: leave the TS contract as is; the door already passes it untyped"
          ],
          "recommendation": "A, so the contract type says what the reference service honours; it needs a packages/spec edit this card's delta did not name"
        },
        {
          "question": "Staging target (Server-Timing runtime <= 500 ms for ~31 ids) is probably not reached by this PR alone (per-id queries ~10 -> ~4.5). Next step if cloud's reading confirms?",
          "options": [
            "A: batch the record pass's per-record reads (one id-in read of the rows, one share listing for all ids) with a parity table like this one",
            "B: bounded concurrency of record passes, after making the per-request context stamps (__rlsMembershipStaged, __verifiedPlatformOwner) concurrency-safe",
            "C: accept the halving; no further card"
          ],
          "recommendation": "Wait for cloud's staging reading after release; if over 500 ms, A, because it removes round trips without touching the once-per-request staging that makes B unsafe today"
        }
      ],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed: SecurityPlugin per-request context stamps (__rlsMembershipStaged set before its resolution awaits) are not safe for concurrent use of one context object; no present caller does that, so no defect today (dormant)"
      ]
    }

    Stamped 2026-11-02T09:27Z.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatperformancepriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions