Skip to content

fix: enforce lease limits and fence recommendation writes - #8

Merged
Akaliup merged 5 commits into
masterfrom
feat/platform-reliability
Nov 1, 2026
Merged

Akaliup merged 5 commits into
masterfrom
feat/platform-reliability

Conversation

@Akaliup

@Akaliup Akaliup commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

目标

本轮只处理三项预审问题,不扩展功能:真实历史库迁移(P1)、指标标签基数(P2)、租约到期严格保护(P3)。基线 fb02522,当前 head dd104c2。

实现

P1 真正的历史数据库迁移

  • backend/migrations/versions/0002_tenant_scoped_idempotency.py:升级开始时先在迁移连接上物化基线 schema(upgrade_legacy_schema),因此真实前租户库只需 stamp 0001_baseline → upgrade head 即可原地升级,不再因缺少 tenant_id / 任务字段失败。
  • SQLite 在迁移事务内 PRAGMA foreign_keys=OFF(pragma 在事务内会被忽略,且知识去重 UPDATE 会开启事务),两张表重建期间子表外键不会阻断父表 DROP;每行都复制到新表,关系保持不变,pragma 仅作用于该连接。
  • 重复升级为 no-op;含跨租户同键数据时 downgrade 因唯一约束失败并保留原行,不丢数据。
  • 测试 backend/tests/legacy_schema.py 按提交 7fd0625 的真实旧 schema 建库(无 tenants/users、tenant_id、attempts/lease_until,保留 uq_daily_scope 与旧全局活跃文档索引),SQLite 与 PostgreSQL 都走官方 stamp → upgrade 路径,并断言 Analysis / Recommendation / Execution / Audit / Knowledge 行数、外键关系与业务唯一约束保留。

P2 指标标签基数

  • route_label(scope) 优先使用路由模板;未匹配路径统一 <unmatched>;限流提前返回的 429 统一 <rate_limited>,不再按原始 URL 生成标签。

P3 租约到期严格保护

  • lease_is_live(now) 在数据库侧比较 lease_until(NULL 视为无租约;恰好等于 now 视为已到期,与 requeue_expired 的 <= now 一致)。
  • create_recommendation、write_job、assert_owned、初始抢占检查、崩溃处理与 LeaseHeartbeat 都追加租约有效性条件,并使用 synchronize_session=False 避免 SQLite naive/aware datetime 在 Python 侧求值。
  • 到期且未被接管的旧 worker 无法创建建议、持久化结果或续租;finalize_job / requeue_expired 未改,保留既有重试、死信与恢复语义。

实际执行的验证

检查 命令 结果
后端全量(含 PostgreSQL) PROFITOPS_TEST_POSTGRES_URL=... .venv/Scripts/python.exe -m pytest backend/tests -q 240 passed, 0 failed, 0 skipped(95.23s,1 个第三方 DeprecationWarning)
历史 SQLite 迁移 .venv/Scripts/python.exe -m pytest backend/tests/test_migrations.py -q 4 passed:真实前租户 schema + stamp 0001_baseline → upgrade head;行数/外键/uq_daily_scope 保留;列宽 200、复合唯一、租约字段、索引到位;重复 upgrade no-op;跨租户同键可用、同租户重复拒绝;重复活跃文档软删除;downgrade 冲突时失败且保留行
历史 PostgreSQL 迁移 PROFITOPS_TEST_POSTGRES_URL=... .venv/Scripts/python.exe -m pytest backend/tests/test_postgres_migrations.py -q 2 passed:一次性 schema,原地升级、数据与外键保留、约束与索引正确、重复 upgrade no-op;downgrade 冲突失败且保留行;schema 用后删除
指标标签基数 .venv/Scripts/python.exe -m pytest backend/tests/test_observability.py -q 16 passed:40 个随机 404 → 一个 <unmatched>;匹配路由 → /api/analyses/{analysis_id};25 个随机 429 → 一个 <rate_limited>
租约到期保护 .venv/Scripts/python.exe -m pytest backend/tests/test_worker.py -q 22 passed:恰好到期、到期未接管(write_job / create_recommendation)、assert_owned、心跳延迟、已被接管、活跃租约正常续租
前端测试 npm --prefix frontend test -- --run 15 passed(1 个测试文件)
TypeScript + 生产构建 npm --prefix frontend run build 通过;Vite 6.4.5,2252 modules
HTTP 冒烟 临时 SQLite + 端口 8015 + LLM_PROVIDER=disabled:python scripts/smoke.py --base-url http://127.0.0.1:8015 15/15 通过,failed=0
Alembic 新库与降级 临时 SQLite:python scripts/migrate.py upgrade head / current / python -m alembic -c backend/alembic.ini check / downgrade base 0002_tenant_scoped_idempotency (head);No new upgrade operations detected;降级成功
GitHub CI(dd104c2) PR #8 checks Backend pytest、Frontend tests and production build、Container image builds 全部 SUCCESS

迁移测试与冒烟均使用独立临时数据库,未修改 data/profitops.db。

未执行 / 未验证

  • 真实进程 kill/restart 本轮未重跑。 上一轮(40a629c)已在临时库完成真实 taskkill /F 与重启恢复;本轮 P3 只增加写入时的租约校验,恢复路径未改,由 22 个 worker 测试覆盖。
  • 真实 LLM 调用:未执行(LLM_API_KEY 为空,自动 CI 同样为空)。
  • 浏览器 E2E:未执行。
  • Python 3.11:本机 .venv 为 3.12.8,未单独验证。

剩余风险

  • SQLite 的租约比较依赖数据库侧时间字符串;生产使用 PostgreSQL 时行为一致,但 SQLite 仅作为测试/本地后端验证。
  • 历史库若存在迁移无法自动判定的脏数据(如活跃文档重复),迁移保留所有行并把多余行置为非活跃;是否人工复核由运维决定(见 docs/RUNBOOK.md)。
  • 不自动合并;合并前需人工审查确认。

🤖 Generated with Claude Code

lz and others added 2 commits November 1, 2026 21:43
- add a lease-fenced analysis queue with retries and dead letters
- add JWT auth, tenant isolation, and server-side role checks
- add Prometheus metrics, rate limiting, and an LLM circuit breaker
- add the Alembic baseline, offline replay evaluation, and runbook
- fence worker writes and close the four review findings

Co-Authored-By: Claude Code <noreply@anthropic.com>
- Dead-letter expired analysis leases after max_attempts instead of
  requeueing forever; use conditional updates so a heartbeat is not
  overwritten by a stale recovery decision.
- Verify worker lease ownership inside the recommendation write
  transaction and propagate LeaseLost so a stale worker aborts before
  writing.
- Add consecutive lease-expiry/dead-letter and old/new worker
  interleaving tests; sync contract, runbook, and verification record.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T16:47:30.653845Z e13a474 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40a629ce67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/app/security.py
The demo tenant keeps the raw key for backward compatibility with rows and
clients created before multi-tenancy.
"""
return key if tenant_id == "demo" else f"{tenant_id}:{key}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make tenant idempotency namespaces disjoint

When the demo tenant submits Idempotency-Key: acme:x, this helper produces exactly the same stored key as tenant acme submitting Idempotency-Key: x; colons are accepted by validate_key. Because both analysis and execution keys remain globally unique, the demo tenant can occupy another tenant's keys, causing otherwise valid analysis requests to return 409 and execution requests to fail their unique constraint. Use a composite (tenant_id, idempotency_key) constraint or an unambiguous namespace that also covers demo keys.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

Comment thread backend/app/security.py
The demo tenant keeps the raw key for backward compatibility with rows and
clients created before multi-tenancy.
"""
return key if tenant_id == "demo" else f"{tenant_id}:{key}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep prefixed keys within the database column limit

On PostgreSQL, a non-demo tenant submitting a valid 100-character idempotency key now gets a database error: adding the tenant prefix exceeds the unchanged VARCHAR(100) columns on both Analysis.idempotency_key and Execution.idempotency_key. For example, tenant acme produces a 105-character stored value. SQLite does not enforce this length, so the SQLite tests miss the regression. Widen both columns with an existing-database migration or use a bounded encoding that preserves the accepted API key length.

Useful? React with 👍 / 👎.

Comment thread backend/app/main.py Outdated
Comment on lines +451 to +454
if row.status != "failed":
raise DomainError("analysis_not_retryable", "只有失败的分析可以手动重试", 409, {"status": row.status})
previous_attempts = row.attempts
row.status = "queued"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Conditionally transition failed jobs when manually retrying

If two retry requests read the same failed analysis before either commits, both pass the status check. After the first request commits and its background task claims or completes the job, the second request's unconditional ORM update can change that running or completed analysis back to queued. This invalidates the active run's status and permits another claim, potentially exceeding the allowed attempts. Perform the transition with a conditional UPDATE against the observed failed status and attempt, and reject the request when no row is changed.

Useful? React with 👍 / 👎.

Comment thread backend/app/worker.py
Comment on lines +128 to +130
select(Analysis).where(
Analysis.status == "running",
or_(Analysis.lease_until.is_(None), Analysis.lease_until <= now),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recover failed jobs whose retry finalization was interrupted

If the process exits after run_analysis commits status="failed" for a retryable error but before process_claimed_job calls finalize_job, the analysis retains its worker ownership and lease without a scheduled retry. This recovery query only considers running rows, while claim queries only consider queued rows, so restarting and waiting for lease expiry never retries or dead-letters that job. Make failure persistence and retry scheduling atomic, or recover expired, unfinalized failed rows as well.

Useful? React with 👍 / 👎.

- enforce (tenant_id, idempotency_key) uniqueness for analyses and executions
- widen stored keys to 200 characters and migrate legacy SQLite/PostgreSQL schemas
- retry failed analyses with a single conditional UPDATE
- recover failed jobs left between run_analysis and finalize_job
- add SQLite/PostgreSQL regressions and update contract/runbook docs

Co-Authored-By: Claude Code <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb02522720

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +183 to +188
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "sqlite":
_upgrade_sqlite()
else:
_upgrade_postgres(bind)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Migrate pre-tenant schemas before applying revision 0002

For a database created by the parent revision, the documented stamp 0001_baseline followed by upgrade head fails: those tables lack tenant_id and the analysis job columns, but stamping does not run the application's compatibility migrations. On SQLite, rebuilding from Analysis.__table__ attempts to copy columns that do not exist; PostgreSQL likewise cannot create the composite constraint without tenant_id. The legacy migration test misses this because its fixture already includes all those columns. Add an explicit schema upgrade before this revision, or ensure the documented migration entry point performs the prerequisite compatibility migration.

Useful? React with 👍 / 👎.

Comment thread backend/app/main.py Outdated
Comment on lines +193 to +194
route = request.scope.get("route")
record_http(request.method, getattr(route, "path", None) or path, response.status_code, duration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound metric labels for unmatched and rate-limited requests

With metrics enabled by default, unauthenticated requests to distinct nonexistent paths create permanently retained counter and histogram entries. This fallback passes the raw path to normalize_path, which only collapses UUIDs and numeric segments, so paths such as /probe-a, /probe-b, etc. remain distinct. Rate limiting does not bound this growth because the 429 branch also records each raw path. Sustained requests therefore grow API memory and the /metrics response without limit. Use a fixed unmatched-route label and bounded route labels in the pre-routing rejection branch.

Useful? React with 👍 / 👎.

…leases

Migration (P1): build the baseline shape inside revision 0002 so a genuine
pre-tenancy database, which can only be stamped 0001_baseline, upgrades in
place instead of failing on missing tenant_id/job columns. SQLite disables
foreign-key enforcement on the migration connection before any DML, because
both table rebuilds drop a parent table while child rows still reference it.
Tests now construct the real old schema (no tenant_id, attempts, lease_until)
for both SQLite and PostgreSQL, assert every business row and FK survives,
and cover re-running the upgrade plus the downgrade refusal.

Metrics (P2): unmatched paths collapse to a fixed <unmatched> label, early
429 responses from rate limiting use <rate_limited>, and matched routes use
the route template. Cardinality-bound tests cover random 404s and 429s.

Leases (P3): create_recommendation, write_job, assert_owned, the initial
claim checks, the crash handler, and LeaseHeartbeat now require the lease to
still be live in SQL. A worker whose lease expired without a takeover can no
longer create recommendations, persist results, or renew its own lease.
finalize_job and requeue_expired keep their retry, dead-letter, and recovery
semantics.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd104c2219

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +218 to +223
def _prune(self, now: float) -> None:
if len(self._buckets) <= self._max_buckets:
return
stale = [key for key, bucket in self._buckets.items() if now - bucket.updated > 3600]
for key in stale:
self._buckets.pop(key, None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the rate limiter's bucket capacity

An unauthenticated client can create unlimited buckets by rotating arbitrary Bearer values: request_context calls rate_limit_key before authentication, and each token hash becomes a new key. Once _max_buckets is exceeded, this pruning logic removes only entries older than one hour, so fresh entries continue accumulating and every new entry triggers a full scan while holding the lock on the request-processing thread. This permits both memory growth and progressively slower requests despite rate limiting being enabled. A local reproduction with max_buckets=8 retained all 10,000 newly created buckets. Enforce a hard capacity through bounded eviction or an overflow bucket rather than relying solely on age-based pruning.

Useful? React with 👍 / 👎.

- add isolated Playwright acceptance coverage for JWT/roles/tenancy,
  worker failure retry, and knowledge lifecycle
- add E2E fixture and legacy duplicate migration review scripts
- run browser E2E against a disposable database and forbid backend reuse
- add Python 3.11 CI job mirroring the locked 3.12 checks
- record actual acceptance results in TEST_REPORT and the sanitized review

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Akaliup
Akaliup merged commit 3876cc6 into master Nov 1, 2026
4 checks passed
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