Repository navigation
fix(state-pg): reclaim expired set_if_not_exists rows, support migration-owned schemas (#240) - #257
Conversation
…ion-owned schemas (#240) Port upstream d88789c9 (vercel/chat#636, chat@4.35.0) and ea025af7 (vercel/chat#913, chat@4.41.0). - set_if_not_exists: conditional upsert (DO UPDATE ... WHERE expires_at IS NOT NULL AND expires_at <= now() RETURNING cache_key) via fetchval, so an expired row no longer blocks a new claim; live and permanent rows are never overwritten. - auto_create_schema (keyword-only, default True) on PostgresStateAdapter and create_postgres_state; False runs a one-query privilege probe instead of DDL and raises StateSchemaError naming every missing table or grant. - Public POSTGRES_SCHEMA_STATEMENTS; README migration SQL + grants, kept in sync by a test. - Tests: mock pool models real PostgreSQL (DO NOTHING never reclaims), injectable clock instead of sleeps, upstream schema-initialization tests, opt-in live suite gated on POSTGRES_TEST_URL.
|
Warning Review limit reachedNext included review available in 50 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (9)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…eate_schema default, pin privilege map (#240) Address PR #257 review: an adapter-created asyncpg pool is closed and reset when connect() fails (asyncpg opens connections eagerly, and disconnect() is a no-op before a successful connect, so retry loops leaked 10 connections per attempt). auto_create_schema=None now means True, matching upstream's ?? true. A unit test pins every has_column_privilege term against upstream's map, and the live revoke loop covers a locks column. README/CHANGELOG wording now matches what the probe reports for missing tables.
# Conflicts: # CHANGELOG.md # docs/UPSTREAM_SYNC.md
|
Merge gate: CI green (Lint & Type Check, test (3.12), test (3.13), Analyze (python), Analyze (actions), CodeQL) on 1c32ba9; local Codex review (gpt-6-astra, xhigh, --base origin/main) on 5f2c6eb: "No actionable regressions found relative to the specified merge base. PostgreSQL unit tests passed (78 passed); lint and formatting checks also passed." (11 live PostgreSQL tests skipped without POSTGRES_TEST_URL); converged pre-merge astra rounds, no new round needed after merging origin/main (#202): only CHANGELOG.md / docs/UPSTREAM_SYNC.md conflicted (additive, both sides kept), and the PR's src/tests/scripts diff vs main is line-for-line identical to the reviewed diff (1220/1220 changed lines; #202 touches cards/modals, this PR touches state/postgres + errors); fidelity_target.json regenerated, delta +0; local full validation 5623 passed, strict 733/733, pyrefly 0 errors; CodeRabbit rate-limited (no findings). Merging with --admin (Protect Main requires a code-owner approval). |
Summary
Ports two upstream Postgres state changes:
Expired claims are reclaimed.
PostgresStateAdapter.set_if_not_existsusedON CONFLICT DO NOTHING, so an expired row inchat_state_cacheblocked every new claim until aget()of that exact key deleted it. Dedupe keys and leases built onset_if_not_exists(for example Telegramupdate_idclaims from [4.41/TG0] Telegram: require webhook verification by default, dedupe repeated updates #224) refused work they should accept. The query is now upstream's conditional upsert:The result is read with
fetchval(...) is not None. Live and permanent (no-TTL) rows are never overwritten.ttl_msfalsy still means permanent, as upstream.Migration-owned schemas. New keyword-only
auto_create_schema: bool | None = True(NonemeansTrue, as upstream's?? true) onPostgresStateAdapterandcreate_postgres_state. WithFalse,connect()runsSELECT 1and then one read-only probe, and never runs DDL. The probe checks per-tableDELETE, per-columnSELECT/INSERT/UPDATE, and the list/queueseqsequence (either an identity column orUSAGE, UPDATE). If anything is missing,connect()raisesStateSchemaError("PostgreSQL state schema is not ready: …")naming the problem (the first relation PostgreSQL cannot resolve, or every object whose grants are missing), followed by the hint. A probe failure is chained as__cause__.New public API.
POSTGRES_SCHEMA_STATEMENTS(tuple[str, ...], the nine statements in execution order), exported fromchat_sdk.state.postgresandchat_sdk.state. Alsochat_sdk.StateSchemaError(ChatError). The README has a new "PostgreSQL state" section with the migration SQL and grants, and a unit test keeps that SQL identical toPOSTGRES_SCHEMA_STATEMENTS.Verify first (from the issue)
I ran the new query through asyncpg against a real PostgreSQL 16.0, an embedded
pgserverinstance. Docker was not running.executetagfetchvalresultINSERT 0 1INSERT 0 0NoneINSERT 0 1NULL)INSERT 0 0NoneThe tags agree with the results, but
RETURNINGis the less ambiguous signal, so I implemented the recommendedfetchval(... RETURNING cache_key) is not None.Upstream commits mapped
d88789c9fix(state-pg): setIfNotExists TTL expiry (vercel/chat#636, chat@4.35.0)PostgresStateAdapter.set_if_not_existsea025af7feat(postgres): allow migration-managed schemas (vercel/chat#913, chat@4.41.0)auto_create_schema,POSTGRES_SCHEMA_STATEMENTS,_TABLE_PRIVILEGES/_SCHEMA_PROBE/_verify_schema,StateSchemaError, README sectionI re-derived
_TABLE_PRIVILEGESfrom Python's SQL inpostgres.py, covering conflict targets,WHERE/RETURNINGcolumns, and theUPDATEsets forset,set_if_not_exists,acquire_lock,extend_lockand the list TTL refresh. It matches upstream's map exactly. The live column-grant test below confirms that every adapter statement works with exactly those column grants.Tests ported (
tests/test_state_postgres.py)index.test.ts,"should allow setIfNotExists to replace expired keys":test_should_allow_set_if_not_exists_to_replace_expired_keys. It replaces the phantomtest_succeeds_after_expired_key, which passed only because the mock'sDO NOTHINGbranch reclaimed expired rows. Real Postgres does not.describe("schema initialization"), all 8 ported intoTestPostgresStateSchemaInitialization:creates every table and index when autoCreateSchema is %skeeps the published migration in sync …probes instead of creating the schema for an external pool via %sprobes only the privileges each table needsrejects connect() when a migration-owned table is missingrejects connect() naming every object …forwards opt-out for a URL from %s and closes the owned poolretries a failed connectivity check without DDL(adapted; see Divergences)postgres.integration.test.ts: all 9 tests ported intoTestPostgresMigrationOwnedSchemaIntegration. This is a superset of the 5 the issue named. It adds the column-grant, the naming, the expired-renew (TTL / no TTL) and the 8-way concurrent one-winner tests. The suite is skipped unlessPOSTGRES_TEST_URLis set, and never readsPOSTGRES_URL. It needsasyncpg, which thedevgroup does not include. Locally, against PG 16:POSTGRES_TEST_URL=… uv run --with asyncpg pytest tests/test_state_postgres.py→ 89 passed (at5f2c6eb).test_permanent_key_is_never_reclaimed,test_zero_ttl_claim_is_permanent,test_concurrent_connect_retries_after_a_failed_connectivity_check.test_fails_when_key_existsnow uses a live TTL row and asserts that its expiry is unchanged.DO NOTHINGbranch never overwrites. A conditional-upsert branch is dispatched before the genericDO UPDATEbranch, inexecute,fetchrowandfetchval. The clock is injectable (MockAsyncpgPool.advance), so everyasyncio.sleepin the file is gone, including a 150 ms one. The weaktest_connect_creates_tables(>= 5CREATEs) was replaced by the exact-sequence upstream port.DO NOTHINGIS NOT NULLguardupdated_atfrom the cacheUPDATEmap__cause__chainFidelity
TS_ROOT=<chat@4.41.1> uv run python scripts/verify_test_fidelity.py --report-target:packages/state-pgis not inMAPPING/TARGET_MAPPING, soscripts/fidelity_target.jsonis unchanged and not part of this diff. Strict at the pin is still 733/733.Divergences (3, within budget; all recorded in
docs/UPSTREAM_SYNC.mdunder Known Non-Parity, plus a new "Postgres state: expired claims and migration-owned schemas" section)StateSchemaError(ChatError); upstream throws a plainError. The message prefix and structure are identical. The hint namesauto_create_schema=True.connect(). This behavior predates this PR and is kept. Python serializesconnect()on anasyncio.Lock, so a caller queued behind a failed attempt retries it. Upstream shares one in-flight promise, so all concurrent callers reject together. The upstream testretries a failed connectivity check without DDLis ported sequentially: it asserts that no DDL or probe runs after the failedSELECT 1,get()raises not-connected, and a laterconnect()succeeds.test_concurrent_connect_retries_after_a_failed_connectivity_checkpins the Python behavior, and a breadcrumb sits at the lock.connect(). asyncpg opensmin_size(10) connections eagerly, anddisconnect()is a no-op untilconnect()succeeds, so a startup retry loop leaked a full pool per failed attempt (live on PG 16: 0 → 10 → 20 → 30 client backends). Upstream's lazypg.Poolkeeps at most one idle client. Python now closes and resets a pool that the failed (or cancelled) attempt created; the next attempt builds a fresh one (same live script: 0 → 0 → 0 → 0). An injected pool is never closed. Pinned bytest_closes_the_owned_pool_when_connect_fails.Consumer impact
auto_create_schema=False. The default behavior is unchanged.POSTGRES_SCHEMA_STATEMENTS(inchat_sdk.state) andStateSchemaError(inchat_sdk).Validation
ruff check,ruff format --check,audit_test_quality.py(0 hard failures),verify_test_fidelity.py --check-docs, and--strictat chat@4.31.0 (all TS tests have Python equivalents): all pass.pytest tests/: 5580 passed, 24 skipped (main baseline: 5561 passed).pyrefly check: 0 errors.Merge gate
Final HEAD:
5f2c6eb(branch already containsorigin/main4817dc0; no merge needed).Independent review findings (4), all fixed in
5f2c6eb:connect()(minor, real). Fixed: a pool created by the failed or cancelled attempt is closed and reset; recorded as divergence 3 above.test_closes_the_owned_pool_when_connect_fails[schema|connectivity|cancelled]fails on the old code; the injected-pool test now assertsclose_calls == 0. Verified live with the reviewer's script (0/0/0 backends instead of 10/20/30).docs/UPSTREAM_SYNC.mdupdated the same way._TABLE_PRIVILEGESentries unpinned (minor, real).test_probes_only_the_privileges_each_table_needsnow asserts the exact set ofhas_column_privilegeterms against a literal copy of upstream'stablePrivilegesmap; the reviewer'sdrop_token_selectmutation now fails it. The live revoke loop also revokesSELECT (token) ON chat_state_locks(live suite 89 passed).auto_create_schema=Noneswitched to verify-only (minor, real). NowNonemeansTrue, matching upstream's?? true; pinned by the newnonecase oftest_creates_every_table_and_index_when_auto_create_schema_is(fails on the old code).Declined: none.
gpt-6-astra: 1 round on
5f2c6eb, verdict: no actionable findings.Bots: CodeRabbit skipped the draft and was rate-limited after ready-for-review; no inline comments or reviews. No gemini comments.
CI on
5f2c6eb: test (3.12), test (3.13), CodeQL, Analyze (actions/python) and Lint & Type Check all pass.Local full validation on
5f2c6eb: ruff check + format, audit (0 hard failures),--check-docs,--strictat chat@4.31.0 (all TS tests have Python equivalents), pytest 5580 passed / 24 skipped, pyrefly 0 errors. Fidelity report-target:Delta vs committed report (HEAD): missing 282 -> 282 (+0).Closes #240
Part of #184