fix(11-07): roll back the savepoint when a swallowed read failed

- beachcomber and lighthouse released their savepoint whenever the inner
  function reported no error; a Gate that counts a failed read as off,
  or a channel function or delete snapshot that swallows one, left the
  caller's Postgres transaction aborted (25P02) and failed the write
- a failed RELEASE now rolls back to the savepoint, as the READMEs promise
- beachcomber gets its testcontainers harness and sync tests
  (TestSyncGates, TestSyncAfterCommit, TestSyncDeleteAndSoftDelete,
  TestSyncFailuresNonFatal, TestServiceSetup); lighthouse gets
  TestBroadcastSwallowedReadFailure
This commit is contained in:
Jakub Zych
2026-09-30 14:26:51 +02:00
parent 6dadbf6957
commit 6df43d45b8
7 changed files with 890 additions and 4 deletions

View File

@@ -379,6 +379,11 @@ func (s *Sprocket) BroadcastChannels(_ context.Context, tx *gorm.DB) ([]string,
if s.Channels == "fail" {
return nil, errors.New("channels failed")
}
if s.Channels == "swallow" {
// A channel function that treats a failed read as "no channels".
_ = tx.Exec(`SELECT * FROM acme_missing_table`).Error
return nil, nil
}
if s.Channels == "abort-tx" {
// A failed statement aborts a Postgres transaction unless it runs
// inside a savepoint.
@@ -750,6 +755,32 @@ func TestBroadcastEdges(t *testing.T) {
})
}
// TestBroadcastSwallowedReadFailure covers a channel function that
// swallows a failed read: the savepoint must still be rolled back, or the
// failed statement leaves the caller's transaction aborted.
func TestBroadcastSwallowedReadFailure(t *testing.T) {
env := newLHEnv(t, nil, nil)
ctx := t.Context()
err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(&Sprocket{Name: "swallowed", Channels: "swallow"}).Error; err != nil {
return err
}
return tx.WithContext(ctx).Create(&Widget{Name: "after-swallow", OwnerID: 0}).Error
})
if err != nil {
t.Fatalf("a swallowed read failure in the broadcast savepoint aborted the write: %v", err)
}
// A single-statement write runs its callbacks inside GORM's own
// transaction; the commit must still succeed.
if err := env.gdb.WithContext(ctx).Create(&Sprocket{Name: "swallowed-implicit", Channels: "swallow"}).Error; err != nil {
t.Fatalf("single-statement write with a swallowed read failure: %v", err)
}
var n int64
if err := env.gdb.Model(&Sprocket{}).Where("name LIKE ?", "swallowed%").Count(&n).Error; err != nil || n != 2 {
t.Fatalf("committed sprockets = %d (err %v), want 2", n, err)
}
}
// TestBroadcastPublishFailure covers D-09: a failed publish is logged at
// Warn without the payload and the one-attempt job is not retried.
func TestBroadcastPublishFailure(t *testing.T) {