Apply a boundary change on every replica, not just the one that received it - #94
Apply a boundary change on every replica, not just the one that received it#94NathanTarbert wants to merge 2 commits into
Conversation
…it#88) The action policy was read from the database once at boot and then kept in the process that held it. A deny rule an administrator added was enforced by whichever server process answered them, while every other one went on deciding with the list it read at boot, and both the admin screen and the computer.policy_loaded audit row reported success. Behind a load balancer the boundary applied to roughly one action in N. set() and reset() now run their write and a pg_notify in one transaction, so the announcement is delivered on commit and a write that rolled back announces nothing. Every replica re-reads the row rather than trusting a payload, which means two changes that overtake each other still converge on what the table says. load() falls back to the configured default when the row is absent, which is what lets a reset reach a process that is already running. The subscription follows startChannelActivityListener: its own connection, because LISTEN holds one for the life of it. The handler is passed as both onnotify and onlisten, so a replica that was disconnected when a change was announced picks it up from the table when the driver subscribes again rather than at the next restart. get() stays synchronous; nothing was added to the path of every action. Covers item 1 of CopilotKit#88 only.
…otKit#91) Two fields moved in core.ts in CopilotKit#87 and meta/0005_snapshot.json was not regenerated, so drizzle-kit generate kept writing a migration nobody asked for and the migrations drift probe kept failing on the dirty tree, on main and on every branch cut from it. The emitted DDL is a no-op against any deployment. accounts.issuer is absent from 0000_schema.sql, added nullable by 0002_sign_in.sql and filled by 0003, and SET NOT NULL was never applied, so DROP NOT NULL drops a constraint that is not there. 0004 already dropped and re-added the sso_providers foreign key with ON DELETE set null, so re-landing it lands it on the constraint it has. The file says all of that at the top, so nobody later goes looking for the schema change it was written for. drizzle-kit check passes and generate now reports no changes, which is what the probe asserts.
|
Superseded by #90, which landed five minutes before you opened this. Genuinely sorry — that is the worst possible timing and none of it was visible to you. Two things in yours are better than what I merged, and I am taking both into a follow-up rather than letting them go: The
The rest we landed on independently and identically: an empty payload so the row stays the only record, Your test approach is different from mine and arguably better for CI: faking The follow-up will credit you. |
Two improvements from Nathan Tarbert's #94, which arrived five minutes after #90 merged and was better than it in these two places. A NOTIFY reaches whoever is listening at the time. A replica that was restarting, or whose connection had dropped, is not, so it missed the announcement and went on enforcing the rules it read at boot until something restarted it. That is the original bug wearing a smaller hat and worse for being intermittent: the fleet disagrees with itself and nothing says so. The handler is now passed as `onlisten` as well, so the row is re-read whenever the driver establishes or re-establishes the subscription, which is exactly when a notification could have been missed. The announcement now goes inside the write transaction, so it is delivered on commit. A write that rolls back announces nothing, and there is no window where the row has changed and the other servers have not been told. Same shape as channel activity, which is what should have been copied. Two tests. The reconnect one fails without `onlisten`. The changelog also picks up the last day of merges, which had run ahead of it.
What this changes
A boundary rule an administrator sets now applies on every server process, not only the one that answered them.
Covers item 1 of #88. Thanks @davidmckayv — grouping those eight under one theme is what made this one easy to pick up and scope, and the pointer to
channels/events.tsas the shape to copy saved the whole design step.createPolicyStorekept the live policy in a factory-closurelet current, filled once byload()at boot (server/src/index.ts:189).set()persisted theaction_policyrow and then updated that one process. Behind a load balancer a new deny rule was enforced by whichever process served the request and roughly one action in N went through it, while the admin screen and thecomputer.policy_loadedaudit row both reported success.set()andreset()now run their write and apg_notifyin the same transaction, and every process re-reads the row when it hears about it. The comment atserver/src/index.ts:377— "a rule added a moment ago applies to the next call" — is true on a fleet now.Items 2 through 8 are untouched. #76 already covers item 4, and #30 and #37 cover item 8.
Design notes worth reviewing
get()is still synchronous. Nothing was added to the path of every action; the re-read happens on the listener's callback.onnotifyandonlisten, so when the driver saysLISTENagain after a dropped connection it re-reads the table. A change announced while a replica was down is picked up at reconnect rather than at the next restart.load()now falls back to the configured default when the row is absent. Previously it leftcurrentuntouched, which meant a reset on one replica could never reach a process that was already running. This is the one behavior change beyond fan-out and it is what makes reset work.startChannelActivityListener:LISTENholds one for the life of the subscription, and taken from the pool it would be one the rest of the server never gets back.Where it runs
action_policyrow. The per-processcurrentstays what it always was, a cache, and is now kept in step with that row.LISTENconnection, re-reads the row, and enforces the new boundary on the next action. An administrator adds a deny rule on replica A and replica B refuses the matching action too; a reset on A lifts it on B.insert … on conflict do updateof one row keyed on the fixed idcurrent— a conditional single statement, not a check-then-write. Two administrators saving at once produce one row, the last commit wins it, and every replica converges on that row because the notification carries no payload. Out-of-order notifications are harmless for the same reason.LISTENsubscription per server process. No new port, nothing through the ingress, nothing polled. A hundred copies are a hundred idle connections each receiving one small notification per policy change, each holding its ownmax: 1connection rather than taking one from the shared pool.Boundary and audit
action-policy-reload-failedline rather than being swallowed, since a process that failed to re-read is deciding against a boundary that is no longer the deployment's.auditEventTypesfor it, and adding one is the vocabulary problem item 5 of State the deployment has to share is held per process, and data that grows with use has nothing bounding it #88 describes rather than something to invent here. Happy to add it in the same PR if you would rather it landed together.Changelog
One line under
Unreleased→Fixed.Proof
server/tests/policy-fanout.test.tssimulates a fleet — one fake deployment holding the single row, several stores over it — and fakesLISTEN/NOTIFYat the driver seam, so the subscription code under test is the real one and no Postgres is needed. Five tests:Each was written to fail first, and mutation-tested afterwards:
set()does not announcereset()does not announceload()no longer reverts to the configured default when the row is goneonnotifya no-op)onlistendropped)current = nextbefore the write commitsEvery mutation was reverted and the suite re-run.
Real persistence is still covered by
policy-durability.integration.test.tsagainst a live database; this file is about which process sees a change.Ran on this branch:
bun run format:check— clean, 365 files.bun run lint— 27 warnings, 1 info, identical to unmodifiedmain.bun run typecheck— clean across app, server and worker.bun run build— clean.bun test— 785 pass, 5 skip, 79 fail, 869 tests across 92 files. Unmodifiedmainon this machine is 780 / 5 / 79 / 864, so the difference is exactly the five new tests and the failure count is unchanged. The 79 are the database integration tests, which need a Postgres this machine does not have; the CItestsjob runs pgvector for them.Reproducing that baseline needs
bun scripts/generate-app-config.tsfirst — without the generatedapp/src/lib/generated/application-config.ts,app/tests/router.test.tserrors and the numbers read 778 / 5 / 80 / 863.bun run typecheckgenerates it as a pre-step.The second commit
fix: bring the migration snapshot back in step with the schemais #93, carried here somigrationscan pass. The drift landed onmainin #87 and every branch cut from it inherits the red check. It disappears when #93 lands, and only the first commit is this PR.