sydtest-persistent-postgresql 0.5.1.0 → 0.5.2.0
raw patch · 4 files changed
+80/−2 lines, 4 files
Files
- CHANGELOG.md +21/−0
- src/Test/Syd/Persistent/Postgresql.hs +21/−1
- sydtest-persistent-postgresql.cabal +1/−1
- test/Test/Syd/Persistent/Postgresql/StandbySpec.hs +37/−0
CHANGELOG.md view
@@ -1,5 +1,26 @@ # Changelog +## [0.5.2.0] - 2026-09-26++### Fixed++* A read against the standby is no longer destroyed by a vacuum on the+ primary. The standby was configured to serve reads and to stay deliberately+ behind, and then left to postgres' defaults on what should happen when+ recovery met one of those reads: the primary was never told what a standby+ reader was holding, so it was free to remove those row versions, and+ replaying the cleanup cancelled the reader. The read came back as `SQLSTATE+ 40001`, or, when the session was holding a snapshot, as a dropped+ connection.++ The standby now sends `hot_standby_feedback`, so the primary keeps what the+ standby's readers still need, and reports it often enough to matter for+ databases that live as briefly as these do. Recovery also waits for a+ conflicting read rather than cancelling it, which covers the conflicts+ feedback does not: a cleanup already in flight, or a lock that replayed DDL+ wants. A replica in a test suite is there to be behind, and a test that+ wants it caught up says so with `awaitReplica`.+ ## [0.5.1.0] - 2026-09-14 ### Changed
src/Test/Syd/Persistent/Postgresql.hs view
@@ -270,7 +270,27 @@ [ [ "port = " ++ show primaryPort, "unix_socket_directories = '" ++ socketDir ++ "'", "listen_addresses = ''",- "hot_standby = on"+ "hot_standby = on",+ -- Tell the primary what this standby's readers still need, so+ -- it keeps those row versions instead of vacuuming them away+ -- and leaving recovery to cancel the reader that wanted them.+ -- The base backup took a replication slot, which is what+ -- carries the feedback, so this has somewhere to be recorded.+ "hot_standby_feedback = on",+ -- Feedback is only sent this often, and the default of ten+ -- seconds is longer than the databases here live, so without+ -- this the primary would learn what to keep long after it had+ -- already thrown it away.+ "wal_receiver_status_interval = 1s",+ -- Feedback closes the window rather than the door: it cannot+ -- retract a cleanup already in flight, and it says nothing+ -- about the conflicts that are not about row versions at all,+ -- such as a lock a replayed DDL wants. Recovery waiting is the+ -- right answer to those here, because a replica in a test+ -- suite is there to be behind, and a test that wanted it+ -- caught up says so with 'awaitReplica'.+ "max_standby_streaming_delay = -1",+ "max_standby_archive_delay = -1" ], ["recovery_min_apply_delay = '" ++ renderReplicaLag replicaConfigLag ++ "'"] ]
sydtest-persistent-postgresql.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: sydtest-persistent-postgresql-version: 0.5.1.0+version: 0.5.2.0 synopsis: An persistent-postgresql companion library for sydtest category: Testing homepage: https://github.com/NorfairKing/sydtest#readme
test/Test/Syd/Persistent/Postgresql/StandbySpec.hs view
@@ -4,6 +4,7 @@ module Test.Syd.Persistent.Postgresql.StandbySpec (spec) where import Database.Persist+import Database.Persist.Sql (rawExecute, runSqlPoolNoTransaction) import Database.PostgreSQL.Simple (SqlError (..)) import Test.Syd import Test.Syd.Persistent.Example@@ -42,3 +43,39 @@ awaitReplica unreplicated mPerson <- onReplica unreplicated $ get i mPerson `shouldBe` Just p++ -- A read on the standby holds a snapshot. If the primary is free to+ -- remove the row versions that snapshot needs, replaying the cleanup+ -- leaves the standby no choice but to cancel the read, and the+ -- application sees SQLSTATE 40001 rather than its data.+ --+ -- Nothing the application does can avoid that, so the standby has to+ -- tell the primary what it is holding.+ it "keeps a read on the replica alive while the primary vacuums what it is reading" $ \pools -> do+ let people = [Person {personName = "Vacuumed " ++ show i, personAge = Just i} | i <- [1 :: Int .. 100]]+ _ <- onPrimary pools $ insertMany people+ awaitReplica pools++ onReplica pools $ do+ -- Read committed takes a new snapshot per statement and lets the+ -- old one go, so there would be nothing held across the cleanup.+ -- An application that reads twice and compares needs this.+ rawExecute "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ" []++ -- Take the snapshot the cleanup below has to be kept away from.+ firstRead <- selectList ([] :: [Filter Person]) []+ liftIO $ length firstRead `shouldBe` 100++ liftIO $ do+ -- Every row gets a new version, so every old version is garbage,+ -- and the vacuum writes the cleanup that conflicts.+ onPrimary pools $ updateWhere ([] :: [Filter Person]) [PersonAge =. Just 0]+ -- VACUUM refuses to run inside a transaction block.+ runSqlPoolNoTransaction+ (rawExecute "VACUUM person" [])+ (replicatedPoolsPrimary pools)+ Nothing+ awaitReplica pools++ secondRead <- selectList ([] :: [Filter Person]) []+ liftIO $ length secondRead `shouldBe` 100