@@ -20,6 +20,7 @@ import (
2020 "testing"
2121 "time"
2222
23+ "github.com/jackc/pgx/v5/pgxpool"
2324 "github.com/stretchr/testify/require"
2425)
2526
@@ -212,6 +213,147 @@ tables:
212213 require .True (t , hasStaleSkipEntry (entries , 2001 , "delete" , "stale_delete_missing_on_n1" ))
213214}
214215
216+ // TestAdvancedRepairPlan_PickFreshestByCommitTS verifies the one-pass
217+ // latest-commit-wins plan: pick_freshest with key: commit_ts keeps the row from
218+ // the side with the newer Spock commit timestamp. It also guards the regression
219+ // where pick_freshest read commit_ts from the (stripped) row instead of the
220+ // spock metadata and therefore always fell back to `tie`.
221+ func TestAdvancedRepairPlan_PickFreshestByCommitTS (t * testing.T ) {
222+ ctx := context .Background ()
223+ qualifiedTableName := "public.customers"
224+
225+ setupDivergence (t , ctx , qualifiedTableName )
226+ diffFile := runTableDiff (t , qualifiedTableName , []string {serviceN1 , serviceN2 })
227+
228+ // Precondition: setupDivergence UPDATEs index 1 & 2 on n2 after the initial
229+ // inserts, so n2's commit_ts for those rows is strictly newer than n1's.
230+ // That ordering is what "latest commit wins" must resolve to.
231+ for _ , idx := range []int {1 , 2 } {
232+ var tsN1 , tsN2 time.Time
233+ require .NoError (t , pgCluster .Node1Pool .QueryRow (ctx ,
234+ fmt .Sprintf ("SELECT pg_xact_commit_timestamp(xmin) FROM %s WHERE index = %d" , qualifiedTableName , idx )).Scan (& tsN1 ))
235+ require .NoError (t , pgCluster .Node2Pool .QueryRow (ctx ,
236+ fmt .Sprintf ("SELECT pg_xact_commit_timestamp(xmin) FROM %s WHERE index = %d" , qualifiedTableName , idx )).Scan (& tsN2 ))
237+ require .Truef (t , tsN2 .After (tsN1 ),
238+ "precondition for index %d: n2 commit_ts %s should be newer than n1 %s" , idx , tsN2 , tsN1 )
239+ }
240+
241+ // One-pass plan: pick_freshest by commit_ts resolves the modified rows to
242+ // the newer (n2) side; apply_from rules propagate the single-sided inserts
243+ // so the tables fully converge.
244+ plan := `
245+ version: 1
246+ tables:
247+ public.customers:
248+ rules:
249+ - name: latest_commit_wins
250+ diff_type: [row_mismatch]
251+ action:
252+ type: custom
253+ helpers:
254+ pick_freshest: { key: commit_ts, tie: n1 }
255+ - name: insert_missing_on_n2
256+ diff_type: [missing_on_n2]
257+ action: { type: apply_from, from: n1, mode: insert }
258+ - name: insert_missing_on_n1
259+ diff_type: [missing_on_n1]
260+ action: { type: apply_from, from: n2, mode: insert }
261+ `
262+ planPath := filepath .Join (t .TempDir (), "repair.yaml" )
263+ require .NoError (t , os .WriteFile (planPath , []byte (plan ), 0o644 ))
264+
265+ task := newTestTableRepairTask ("" , qualifiedTableName , diffFile )
266+ task .RepairPlanPath = planPath
267+ require .NoError (t , task .ValidateAndPrepare ())
268+ require .NoError (t , task .Run (true ))
269+
270+ assertNoTableDiff (t , qualifiedTableName )
271+
272+ // The newer (n2) side won for the modified rows, on BOTH nodes.
273+ for _ , idx := range []int {1 , 2 } {
274+ want := fmt .Sprintf ("modified.email%d@example.com" , idx )
275+ var e1 , e2 string
276+ require .NoError (t , pgCluster .Node1Pool .QueryRow (ctx ,
277+ fmt .Sprintf ("SELECT email FROM %s WHERE index = %d" , qualifiedTableName , idx )).Scan (& e1 ))
278+ require .NoError (t , pgCluster .Node2Pool .QueryRow (ctx ,
279+ fmt .Sprintf ("SELECT email FROM %s WHERE index = %d" , qualifiedTableName , idx )).Scan (& e2 ))
280+ require .Equalf (t , want , e1 , "index %d on n1 should be the newer (n2) value" , idx )
281+ require .Equalf (t , want , e2 , "index %d on n2 should be the newer (n2) value" , idx )
282+ }
283+ }
284+
285+ // TestAdvancedRepairPlan_PickFreshestByAppColumn verifies pick_freshest with an
286+ // application-level timestamp column (subscription_date). Unlike commit_ts this
287+ // is ordinary row data, so it is immune to freezing and works even where
288+ // commit_ts would be NULL. index 1 is newer on n2, index 2 is newer on n1, so
289+ // the winning side differs per row.
290+ func TestAdvancedRepairPlan_PickFreshestByAppColumn (t * testing.T ) {
291+ ctx := context .Background ()
292+ qualifiedTableName := "public.customers"
293+ env := newSpockEnv ()
294+ t .Cleanup (func () { resetSharedTable (t , "customers" ) })
295+
296+ // Clean slate on both nodes.
297+ for _ , pool := range []* pgxpool.Pool {pgCluster .Node1Pool , pgCluster .Node2Pool } {
298+ env .withRepairMode (t , ctx , pool , func (conn * pgxpool.Conn ) {
299+ _ , err := conn .Exec (ctx , fmt .Sprintf ("TRUNCATE TABLE %s CASCADE" , qualifiedTableName ))
300+ require .NoError (t , err )
301+ })
302+ }
303+
304+ // Same PKs on both nodes but differing first_name and subscription_date, so
305+ // each row is a row_mismatch resolvable by freshness of subscription_date.
306+ insert := func (pool * pgxpool.Pool , idx int , firstName , subDate string ) {
307+ env .withRepairMode (t , ctx , pool , func (conn * pgxpool.Conn ) {
308+ _ , err := conn .Exec (ctx ,
309+ fmt .Sprintf ("INSERT INTO %s (index, customer_id, first_name, last_name, email, subscription_date) VALUES ($1,$2,$3,$4,$5,$6)" , qualifiedTableName ),
310+ idx , fmt .Sprintf ("CUST-%d" , idx ), firstName , "Last" , fmt .Sprintf ("e%d@example.com" , idx ), subDate )
311+ require .NoError (t , err )
312+ })
313+ }
314+ insert (pgCluster .Node1Pool , 1 , "N1" , "2020-01-01 00:00:00" ) // index 1: n2 newer -> n2 wins
315+ insert (pgCluster .Node2Pool , 1 , "N2" , "2021-01-01 00:00:00" )
316+ insert (pgCluster .Node1Pool , 2 , "N1" , "2023-01-01 00:00:00" ) // index 2: n1 newer -> n1 wins
317+ insert (pgCluster .Node2Pool , 2 , "N2" , "2022-01-01 00:00:00" )
318+
319+ diffFile := runTableDiff (t , qualifiedTableName , []string {serviceN1 , serviceN2 })
320+
321+ plan := `
322+ version: 1
323+ tables:
324+ public.customers:
325+ rules:
326+ - name: latest_by_subscription_date
327+ diff_type: [row_mismatch]
328+ action:
329+ type: custom
330+ helpers:
331+ pick_freshest: { key: subscription_date, tie: n1 }
332+ `
333+ planPath := filepath .Join (t .TempDir (), "repair.yaml" )
334+ require .NoError (t , os .WriteFile (planPath , []byte (plan ), 0o644 ))
335+
336+ task := newTestTableRepairTask ("" , qualifiedTableName , diffFile )
337+ task .RepairPlanPath = planPath
338+ require .NoError (t , task .ValidateAndPrepare ())
339+ require .NoError (t , task .Run (true ))
340+
341+ assertNoTableDiff (t , qualifiedTableName )
342+
343+ // index 1 -> n2 (newer subscription_date) won; index 2 -> n1 won. The whole
344+ // winning row is applied, so first_name identifies the side that won.
345+ want := map [int ]string {1 : "N2" , 2 : "N1" }
346+ for idx , wantName := range want {
347+ var f1 , f2 string
348+ require .NoError (t , pgCluster .Node1Pool .QueryRow (ctx ,
349+ fmt .Sprintf ("SELECT first_name FROM %s WHERE index = %d" , qualifiedTableName , idx )).Scan (& f1 ))
350+ require .NoError (t , pgCluster .Node2Pool .QueryRow (ctx ,
351+ fmt .Sprintf ("SELECT first_name FROM %s WHERE index = %d" , qualifiedTableName , idx )).Scan (& f2 ))
352+ require .Equalf (t , wantName , f1 , "index %d on n1 should be the fresher side" , idx )
353+ require .Equalf (t , wantName , f2 , "index %d on n2 should be the fresher side" , idx )
354+ }
355+ }
356+
215357func listStaleSkipLogs (t * testing.T ) []string {
216358 t .Helper ()
217359 paths , err := filepath .Glob (filepath .Join ("reports" , "*" , "stale_repair_skips_*.json" ))
0 commit comments