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:
@@ -39,7 +39,7 @@ The `centrifugo` sub-package is the Centrifugo driver. It has a hand-rolled `net
|
||||
- `lighthouse.BroadcastTTLer` or `Binding.TTL` replaces the ttl.
|
||||
|
||||
The event name is `{action}.{alias}` lowercased: `lighthouse.ActionCreated`, `lighthouse.ActionUpdated` or `lighthouse.ActionDeleted`, then an alias that defaults to `<plugin>.<model>` (the Go package name, or the parent directory of a `models` package, and the type name). The payload builder receives a `lighthouse.Event` with the action, the `lighthouse.Actor`, the timestamp and the ttl. A soft delete counts as a delete. A delete's channels and payload are computed from a fresh read of the row before it is deleted, so deleting a model that holds only its id still broadcasts. An empty channel list means no broadcast.
|
||||
- Transactional delivery. GORM callbacks (`lighthouse.CallbackAfterCreate`, `lighthouse.CallbackAfterUpdate`, `lighthouse.CallbackSnapshot` and `lighthouse.CallbackAfterDelete`) are installed through `lagoon.OnDatabase`. The after-write callbacks run after the model's own after hook and before GORM commits the transaction it opens for a single-statement write, so they enqueue a `lighthouse.BroadcastArgs` job on the write's `*sql.Tx` in every case (an explicit transaction or a single `Create`, `Save` or `Delete`), on the `realtime.broadcast_queue` queue with MaxAttempts 1 and the `realtime.broadcast_timeout` timeout. Channel and payload queries and the enqueue run inside a savepoint, so a failure is rolled back to it, logged at Warn with channels and event (never the payload), and the write goes on. A write with a zero primary key, such as `Model(&T{}).Where(…).Updates(…)`, is not broadcast; bulk paths suppress and emit instead. The null driver, or a driver whose `Enabled` reports false (Centrifugo without an API key), gets no jobs.
|
||||
- Transactional delivery. GORM callbacks (`lighthouse.CallbackAfterCreate`, `lighthouse.CallbackAfterUpdate`, `lighthouse.CallbackSnapshot` and `lighthouse.CallbackAfterDelete`) are installed through `lagoon.OnDatabase`. The after-write callbacks run after the model's own after hook and before GORM commits the transaction it opens for a single-statement write, so they enqueue a `lighthouse.BroadcastArgs` job on the write's `*sql.Tx` in every case (an explicit transaction or a single `Create`, `Save` or `Delete`), on the `realtime.broadcast_queue` queue with MaxAttempts 1 and the `realtime.broadcast_timeout` timeout. Channel and payload queries and the enqueue run inside a savepoint, so a failure (also one a channel or payload function swallows) is rolled back to it, logged at Warn with channels and event (never the payload), and the write goes on. A write with a zero primary key, such as `Model(&T{}).Where(…).Updates(…)`, is not broadcast; bulk paths suppress and emit instead. The null driver, or a driver whose `Enabled` reports false (Centrifugo without an API key), gets no jobs.
|
||||
- The broadcast job lowercases the channels and adds the `realtime.broadcast_namespace` prefix unless a channel already has it. It then publishes to one channel or broadcasts to several. A failure is logged as `realtime: broadcast failed` and is not retried. Delivery order across separate jobs is not guaranteed. The payload travels inside the job as a JSON string, so its key order survives Postgres JSONB.
|
||||
- Suppression: `lighthouse.WithoutBroadcasting` silences one model type for writes made with the context it hands to its function. Other types still broadcast, and a write through an outer context is not suppressed. `lighthouse.Service.Emit` enqueues one `lighthouse.Broadcast` on the caller's transaction and returns its error. Together they turn N row events into one summary event.
|
||||
- Centrifugo driver (`centrifugo.Driver`, driver name `centrifugo`):
|
||||
|
||||
@@ -469,7 +469,12 @@ func (s *Service) inSavepoint(db *gorm.DB, fn func(tx *gorm.DB) error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if inTx {
|
||||
if inTx && tx.Exec("RELEASE SAVEPOINT "+savepoint).Error != nil {
|
||||
// A statement inside fn failed although fn did not report it (a
|
||||
// channel or payload function that treats a failed read as "no
|
||||
// broadcast", or the delete snapshot's reload): the transaction is
|
||||
// aborted and only a rollback to the savepoint keeps the write alive.
|
||||
tx.RollbackTo(savepoint)
|
||||
tx.Exec("RELEASE SAVEPOINT " + savepoint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user