Repository navigation
feat(db): accounts on native Postgres — #251 - #253
Conversation
Users, cookie sessions, Discord OAuth states, preview sign-in tickets and the staff audit log move to src.database, with the locking recipes from the foundation PR (X1, X2, X3, A, B, C) and a two-connection race test for each. Times leaving the accounts code keep the YYYY-MM-DDTHH:MM:SSZ form. Where unconverted areas meet accounts, temporary bridges are marked PG-MIGRATION. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
…ted race tests Without DATABASE_URL, signed-out pages never touch the pool; sign-in and stale session cookies answer 503 accounts_unavailable, and signing out still works. session_user_in refuses lock=True on the SQLite bridge. The studio's site source and the preview scrub carry PG-MIGRATION marks. The locking tests now see lock waits in pg_stat_activity, hold rows by hand to isolate A and C from the placeholders lock, and race view_still_open's FOR SHARE against ending the view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s database Role changes, session revocation, server writes, view-as start and the staff-cookie restore check for the accounts database before opening a transaction, and a preview's callback checks before spending dev's ticket. Without DATABASE_URL each answers 503 accounts_unavailable and writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reviewed by Muse (muse-wonder-full) over three rounds; round 3 returned VERDICT: OK (no fail-open anywhere, every no-database path answers 503, the race tests were mutation-checked). Merging into the |
Accounts move to native Postgres (#251): users, cookie sessions, Discord OAuth states, preview sign-in tickets and the staff audit log are now read and written through
src.databasewith%splaceholders,RETURNING,ON CONFLICTand the new column types. Every accounts path from the foundation PR's table has its locking recipe, and each recipe has a test that races two connections against the real Postgres. Other areas stay on SQLite; where they meet accounts there is a temporary bridge markedPG-MIGRATION.Previews of the
postgresbranch cannot sign in until the host-plumbing PR lands.ps-preview.shgives a preview no Postgres and noDATABASE_URL, and accounts now live only in Postgres. Such a preview, or a local run without Postgres, serves signed-out pages normally: nothing touches the database until a session token exists. Sign-in (/auth/discord/start) and any request carrying a session cookie answer503 accounts_unavailableinstead of a 500. So do the staff writes (role change, session revocation, server actions, view-as start) and restoring an admin from the staff cookie alone (GET /account,POST /auth/view-as/stop). Each checks for the database before opening a transaction, so nothing is written. A preview's callback checks before spending dev's ticket. Signing out still clears cookies, and the view-as guard lets writes through, because no view can exist without the database. The preview data-copy scrub ofuser_sessionsanddiscord_oauth_statesnow hits stale SQLite tables; it carries aPG-MIGRATION: host plumbingmark.Files converted
backend/src/auth/users.py: sign-in, sessions, OAuth states,session_user_in(conn, token, lock=False),drop_view_leftovers,PLACEHOLDERS_LOCK.backend/src/auth/audit.py:recordwritesdetail_jsonasjsonb;record_refusaluses its own Postgres transaction.backend/src/auth/admin.py: role changes, session revocation, staff list and lookup.backend/src/auth/admin_cli.py:_set_rooton Postgres; new--database-url(defaultDATABASE_URL).backend/src/auth/view_as.py: start, stop, end,end_lapsed,view_still_open,refuses_write.backend/src/auth/preview_sign_in.py: ticket issue and redeem.backend/src/auth/players.py: the website accounts in the Players directory, and its two audit writes.backend/src/api/admin_routes.py:_server_write.backend/src/api/database_routes.py: the studio's per-database query counts readdetail_json ->> 'database'.Bridged where another area meets accounts (each marked, see below):
auth/microsoft_link.py(still_valid),skins/discord_link.py(_cancel_microsoft_attempts),luckperms/views.py(_account) andluckperms/changes.py(_target_account_role,_current_actor). These readusersanduser_sessionsfrom Postgres, because that is now the only place they are written.Locking, path by path
Tests are in
backend/src/auth/test_locking.py. One transaction holds a lock, either by hand on its own connection or through aGatethat pauses a path just after a chosen statement. The competing path runs on another thread. The test readspg_stat_activityto see that thread's backend waiting withwait_event_type = 'Lock', and checks which kind of lock:advisoryfor X3,transactionidortuplefor a row lock. It then lets the holder commit and checks that the waiter acted on the holder's result.Every path that calls
drop_view_leftoverstakes X3 first, so two whole calls of such a path are serialised by X3 before any row lock matters. Tests that race two whole calls are therefore listed as X3 tests. To isolate a row-lock or constraint recipe, the test holds the competing row by hand without the advisory lock. I checked that each isolating test fails when its recipe is removed:_lock_accountsdropped,FOR UPDATEorFOR SHAREremoved,ON CONFLICTremoved, X3 dropped fromend,DELETE … RETURNINGreplaced by a plain read.users.consume_stateDELETE … WHERE state_hash = %s RETURNING return_to, expires_attest_a_discord_state_is_spent_onceusers.sign_inINSERT … ON CONFLICT (discord_user_id) DO UPDATE … RETURNING idtest_a_first_sign_in_joins_an_account_being_made(an uncommitted insert of the same id held by hand). X3:test_every_leftover_sweep_takes_the_placeholders_lock_firstadmin_cli._set_rootORDER BY id FOR UPDATE, then counttest_root_locks_wait_for_a_panel_demotion(a root's row held by hand). X3:test_two_cli_removals_cannot_leave_no_rootadmin._staff_write(role change, revocation)usersrowsORDER BY id FOR UPDATE, then X1test_a_role_change_waits_for_a_change_to_its_target(the target's row held by hand). X3:test_two_roots_demoting_each_other_run_one_after_the_otherview_as.startON CONFLICT (discord_user_id) DO NOTHING, re-select), X2 through_opentest_a_view_being_opened_keeps_its_placeholder. C:test_a_placeholder_never_collides_with_a_new_accountview_as.stop,view_as.end_end_whereselects the viewsFOR UPDATE OF s)test_ending_a_view_locks_its_session_row(no advisory lock). X3:test_two_requests_ending_one_view_wait_for_each_otherview_as.end_lapsedtest_every_leftover_sweep_takes_the_placeholders_lock_firstpreview_sign_in.redeemDELETE FROM preview_sign_in_tickets t USING users u … RETURNING …test_a_preview_ticket_is_redeemed_onceadmin_routes._server_writesession_user_in(conn, token, lock=True)in the audit entry's transactiontest_a_server_action_and_a_demotion_never_crosssession_user_in(lock=True)itselfFOR SHARE OF s, u;lock=Falsetakes nothing;lock=Truewith a SQLite connection raisesTypeErrortest_a_staff_recheck_holds_off_demotion_and_revocation,test_an_ordinary_session_read_takes_no_lock,test_a_lock_cannot_be_asked_of_the_sqlite_bridgeview_as.view_still_openFOR SHAREon a Postgres connectiontest_a_view_still_open_check_holds_the_view_until_its_write_commits(ending the view waits for the check's transaction). Its only caller, Skins, still passes SQLite, so live it goes through the bridge, whose false answertest_profile_is_not_opened_for_a_view_that_ended_mid_requestcoversX3 is taken as the first statement of every transaction that calls
drop_view_leftovers:sign_in,_staff_write,_set_root,view_as.start,stop,endandend_lapsed. Never insidedrop_view_leftovers. The foundation PR lists no read-then-write races outsideBEGIN IMMEDIATEfor this area, so there were none to fix beyond the table.Output formats
Times are
timestamptznow, but nothing a client parses changes. Every time that leaves the accounts code is stillYYYY-MM-DDTHH:MM:SSZ, made byusers.api_time():session_user,session_user_in,guild_state,record_guild_check) carriesexpires_at,guild_checked_atandcreated_atin that form. Cookie lifetimes (seconds_left),/account(guild.checked_at,viewing_as.expires_at), the guild check, Profile sessions opened during a view, LuckPerms and diagnostics all keep reading it as before.POST /auth/preview/redeem) returnsguild_checked_atin the Z form, the only one a preview's_checked_ataccepts. A preview passes that string back tosign_in, which parses it into adatetime.view_as.startreturnsexpires_atin the Z form, which sets the view cookie's lifetime./admin/staff,/admin/accounts/lookup, the account in/admin/players/{uuid}) returnscreated_atandlast_login_atin the Z form.Stored times now keep microseconds; the Z form drops them, as the old strings did.
guild_memberandplaceholderare booleans, andguild_membercomes back asbool, where it used to be0/1. Every reader already used it as a truth value.admin_audit.detail_jsonisjsonband reads back as a dict. Tests pin the formats intest_session_times_keep_the_api_form,test_admin_views_the_site_as_a_player,test_dev_start_hands_a_signed_in_player_to_the_previewandtest_profile_shows_the_website_account.admin.change_roleandrevoke_sessionsanswer 404 for a target id pastbigintinstead of hitting a database error.PG-MIGRATIONmarksEach mark names the area whose conversion removes it. The temporary second paths for a
sqlite3.Connectionare inaudit.record,session_user_in(which refuseslock=Truethere) andview_still_open. The other marks are reads or deletes on tables that still live in SQLite, done on a separate connection until their area moves.auth/users.pysession_user_inluckperms/changes.py: the session is read from Postgres on its own connectionauth/audit.pyrecordauth/view_as.pyview_still_openstart_linked_profile_session: checked on its own Postgres connection, which reopens a narrow window between the check and the caller's commitauth/users.pydrop_view_leftoversauth/view_as.pyrefuses_writeauth/view_as.py_link_for_playerdiscord_linksread from SQLiteauth/view_as.py_players_forauth/admin.py_with_minecraftauth/players.py_site_rowsauth/players.pycharacter_pictureauth/discord_names.pyrefresh_alldiscord_linksread from SQLiteauth/account_overview.pyrankauth/microsoft_link.pystill_validauth/admin_cli.py--dbskins/discord_link.py_cancel_microsoft_attemptsluckperms/views.py_accountluckperms/changes.py_target_account_roleluckperms/changes.py_current_actordbstudio/studio.pysourcesprovince.db; its description now says accounts and the audit log are in Postgres and their tables there are stalescripts/ps-preview.shdata-copy scrubuser_sessionsanddiscord_oauth_statesin the copied SQLite file, which no longer holds them; previews have noDATABASE_URLUntil those areas convert, the bridged reads and writes are not atomic with the SQLite transaction around them. The branch is not deployed, and each area's PR puts the join or the lock back on one connection.
Tests
The accounts tests run on the
postgresfixture:test_admin,test_view_as,test_auth_routes,test_preview_sign_in,test_microsoft,test_playersandtest_staff_tools. Discord links, roster rows, codes and Profile sessions are still seeded in SQLite, through thedatabasefixture.src/auth/testing.pyholds small Postgres helpers.Tests from other areas that sign in through accounts also take
postgres: LuckPerms, servers, rail, diagnostics and the database studio. The studio's SQLite tests now querydiscord_linksin place ofusers, which no longer has rows inprovince.db. They also allow the extrapostgressource thatDATABASE_URLadds. No studio test treats an edit to the SQLite accounts tables as meaningful.test_without_an_accounts_database_pages_work_signed_out_and_sign_in_is_503covers the no-database case: a signed-out/account, a 503 from sign-in and for a stale cookie, sign-out, and the view-as guard.test_staff_writes_without_an_accounts_database_are_503_and_change_nothingcovers the staff writes and the staff-cookie restore: each answers 503, and no role, session or audit row changes.test_preview_callback_without_an_accounts_database_keeps_the_ticketchecks that the ticket is never redeemed.Two SQLite-only tests stay:
test_migration_adds_role_to_existing_usersandtest_migration_adds_acting_user_idcoverskins/db._upgrade, which still runs until area 10 removes it.Full backend suite against
pgvector/pgvector:pg17, withTEST_DATABASE_URLset:postgresbefore this PR (b5e161e): 2,432 passed, 4 skipped, 175 subtests passed (14 min 55 s)test_locking.py,test_session_times_keep_the_api_formand three no-database tests (test_without_an_accounts_database_pages_work_signed_out_and_sign_in_is_503,test_staff_writes_without_an_accounts_database_are_503_and_change_nothing,test_preview_callback_without_an_accounts_database_keeps_the_ticket)🤖 Generated with Claude Code