Skip to content

Commit e22fed4

Browse files
committed
fix(advancer): preserve application failure fences
1 parent 0debf9c commit e22fed4

5 files changed

Lines changed: 164 additions & 22 deletions

File tree

internal/advancer/advancer.go

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,18 @@ func (s *Service) Step(ctx context.Context) (bool, error) {
8181
}
8282

8383
// Update the machine manager with any new or disabled applications
84-
err := s.machineManager.UpdateMachines(ctx)
85-
if err != nil {
86-
return false, err
84+
updateErr := s.machineManager.UpdateMachines(ctx)
85+
if updateErr != nil && !manager.IsOnlyApplicationFailurePersistenceErrors(updateErr) {
86+
return false, updateErr
8787
}
8888

8989
// Get all applications with active machines (returned sorted by ID).
9090
apps := s.machineManager.Applications()
9191
if len(apps) == 0 {
92-
return false, nil
92+
return false, updateErr
9393
}
9494
anyWork := false
95-
var errs []error
95+
errs := []error{updateErr}
9696
for _, app := range apps {
9797
hadWork, err := s.stepApp(ctx, app)
9898
if err != nil {
@@ -266,13 +266,7 @@ func (s *Service) processInputs(ctx context.Context, app *Application, inputs []
266266
"index", input.Index,
267267
"error", err)
268268

269-
if dbErr := appstatus.SetFailed(ctx, s.Logger, s.repository, app, err.Error()); dbErr != nil {
270-
s.Logger.Error("Failed to persist FAILED status — machine will be closed "+
271-
"but the app status remains unchanged in DB; it may be re-created "+
272-
"from the last snapshot on the next tick. If the root cause "+
273-
"persists, this may loop.",
274-
"application", app.Name, "db_error", dbErr)
275-
}
269+
s.markApplicationFailed(ctx, app, err.Error())
276270

277271
// Eagerly close the machine to release the child process.
278272
// The app has failed, so no further operations will succeed.
@@ -354,6 +348,20 @@ func (s *Service) processInputs(ctx context.Context, app *Application, inputs []
354348
return nil
355349
}
356350

351+
// markApplicationFailed persists FAILED or installs a local fence when the
352+
// status write cannot be confirmed. Keeping those operations together prevents
353+
// a failed application from being handed more work while durability is retried.
354+
func (s *Service) markApplicationFailed(ctx context.Context, app *Application, reason string) {
355+
if err := appstatus.SetFailed(ctx, s.Logger, s.repository, app, reason); err != nil {
356+
s.machineManager.FenceApplicationFailure(app, reason)
357+
s.Logger.Error(
358+
"Could not persist FAILED application status; the application remains fenced until the write is retried",
359+
"application", app.Name,
360+
"db_error", err,
361+
)
362+
}
363+
}
364+
357365
func (s *Service) isEpochLastInput(ctx context.Context, app *Application, input *Input) (bool, error) {
358366
if app == nil || input == nil {
359367
return false, fmt.Errorf("application and input must not be nil")
@@ -421,10 +429,7 @@ func (s *Service) handleEpochAfterInputsProcessed(ctx context.Context, app *Appl
421429
// If the runtime was destroyed (e.g., child process crashed),
422430
// mark the app as failed to avoid an infinite retry loop.
423431
if errors.Is(err, manager.ErrMachineClosed) {
424-
if dbErr := appstatus.SetFailed(ctx, s.Logger, s.repository, app, err.Error()); dbErr != nil {
425-
s.Logger.Error("Failed to persist FAILED status for crashed machine",
426-
"application", app.Name, "db_error", dbErr)
427-
}
432+
s.markApplicationFailed(ctx, app, err.Error())
428433
}
429434
return fmt.Errorf("failed to get outputs proof from machine: %w", err)
430435
}

internal/advancer/advancer_test.go

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"testing"
2020
"time"
2121

22+
"github.com/cartesi/rollups-node/internal/appstatus"
2223
"github.com/cartesi/rollups-node/internal/manager"
2324
. "github.com/cartesi/rollups-node/internal/model"
2425
"github.com/cartesi/rollups-node/internal/repository"
@@ -118,6 +119,10 @@ func (s *AdvancerSuite) TestServiceInterface() {
118119
// Test service interface methods
119120
require.True(advancer.Alive())
120121
require.True(advancer.Ready())
122+
machineManager.PendingApplicationFailures = true
123+
require.False(advancer.Ready())
124+
machineManager.PendingApplicationFailures = false
125+
require.True(advancer.Ready())
121126
require.Empty(advancer.Reload())
122127
require.Equal(advancer.Name, advancer.String())
123128

@@ -224,6 +229,40 @@ func (s *AdvancerSuite) TestStep() {
224229
require.Contains(err.Error(), "update machines error")
225230
})
226231

232+
s.Run("Error/UnconfirmedApplicationFailureStatus", func() {
233+
require := s.Require()
234+
persistenceErr := &manager.ApplicationFailurePersistenceError{
235+
ApplicationID: 7,
236+
WriteErr: errors.New("status write unavailable"),
237+
ReadErr: errors.New("status read unavailable"),
238+
}
239+
healthy := newMockMachine(8)
240+
machineManager := &MockMachineManager{
241+
Map: map[int64]*MockMachineInstance{
242+
healthy.Application.ID: newMockInstance(healthy),
243+
},
244+
UpdateMachinesError: persistenceErr,
245+
PendingApplicationFailures: true,
246+
}
247+
repo := &MockRepository{
248+
GetEpochsReturn: map[common.Address][]*Epoch{
249+
healthy.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}},
250+
},
251+
GetInputsReturn: map[common.Address][]*Input{
252+
healthy.Application.IApplicationAddress: {
253+
newInput(healthy.Application.ID, 0, 0, marshal(randomAdvanceResult(0))),
254+
},
255+
},
256+
}
257+
advancer, err := newMockAdvancerService(machineManager, repo)
258+
require.NoError(err)
259+
260+
_, err = advancer.Step(context.Background())
261+
require.ErrorIs(err, manager.ErrApplicationFailureNotDurable)
262+
require.Len(repo.StoredResults, 1, "the healthy application must still advance")
263+
require.False(advancer.Ready())
264+
})
265+
227266
s.Run("Error/GetInputs", func() {
228267
require := s.Require()
229268
env := s.setupOneApp()
@@ -295,6 +334,48 @@ func (s *AdvancerSuite) TestStep() {
295334
// app2's input was processed despite app1's failure
296335
require.Len(repo.StoredResults, 1)
297336
})
337+
338+
s.Run("LiveCycleLimitWriteFailureFencesAppWithoutBlockingHealthySibling", func() {
339+
require := s.Require()
340+
mm := newMockMachineManager()
341+
limited := newMockMachine(1)
342+
healthy := newMockMachine(2)
343+
limitErr := fmt.Errorf(
344+
"advance execution reached configured cycle limit: %w",
345+
pkgmachine.ErrReachedLimitMcycle,
346+
)
347+
limited.AdvanceError = limitErr
348+
mm.Map[limited.Application.ID] = newMockInstance(limited)
349+
mm.Map[healthy.Application.ID] = newMockInstance(healthy)
350+
repo := &MockRepository{
351+
GetEpochsReturn: map[common.Address][]*Epoch{
352+
limited.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}},
353+
healthy.Application.IApplicationAddress: {{Index: 0, Status: EpochStatus_Open}},
354+
},
355+
GetInputsReturn: map[common.Address][]*Input{
356+
limited.Application.IApplicationAddress: {
357+
newInput(limited.Application.ID, 0, 0, []byte("limited input")),
358+
},
359+
healthy.Application.IApplicationAddress: {
360+
newInput(healthy.Application.ID, 0, 0, marshal(randomAdvanceResult(0))),
361+
},
362+
},
363+
UpdateApplicationStatusError: errors.New("FAILED write unavailable"),
364+
}
365+
svc, err := newMockAdvancerService(mm, repo)
366+
require.NoError(err)
367+
368+
_, err = svc.Step(context.Background())
369+
370+
require.ErrorIs(err, pkgmachine.ErrReachedLimitMcycle)
371+
require.Equal(
372+
appstatus.NormalizeReason(limitErr.Error()),
373+
mm.RecordedApplicationFailures[limited.Application.ID],
374+
)
375+
require.False(svc.Ready(), "an unconfirmed FAILED write must fail readiness immediately")
376+
require.Len(repo.StoredResults, 1, "the healthy sibling must still advance")
377+
require.Equal(healthy.Application.ID, repo.StoredAppIDs[0])
378+
})
298379
}
299380

300381
func (s *AdvancerSuite) TestGetUnprocessedInputs() {
@@ -601,6 +682,11 @@ func (s *AdvancerSuite) TestContextCancellation() {
601682
// The expired context prevents the immediate database write, so the
602683
// application failure is queued for a later status-write retry.
603684
require.Zero(env.repo.ApplicationStatusUpdates)
685+
require.Equal(
686+
context.DeadlineExceeded.Error(),
687+
env.mm.RecordedApplicationFailures[env.app.Application.ID],
688+
)
689+
require.True(env.mm.PendingApplicationFailures)
604690
require.Equal(1, env.mm.Map[env.app.Application.ID].closeCalls)
605691
require.True(logs.contains(slog.LevelError, "Error executing advance"))
606692
require.False(logs.contains(
@@ -893,6 +979,23 @@ func (s *AdvancerSuite) TestHandleEpochAfterInputsProcessed() {
893979
require.Equal(ApplicationStatus_Failed, env.repo.LastApplicationStatus)
894980
})
895981

982+
s.Run("EmptyEpochIndex0ErrMachineClosedWriteFailureQueuesDurableFence", func() {
983+
require := s.Require()
984+
env := s.setupOneApp()
985+
env.app.OutputsProofError = manager.ErrMachineClosed
986+
env.repo.UpdateApplicationStatusError = errors.New("FAILED write unavailable")
987+
epoch := &Epoch{Index: 0, Status: EpochStatus_Closed, InputIndexLowerBound: 0, InputIndexUpperBound: 0}
988+
989+
err := env.service.handleEpochAfterInputsProcessed(context.Background(), env.app.Application, epoch)
990+
991+
require.ErrorIs(err, manager.ErrMachineClosed)
992+
require.Equal(
993+
appstatus.NormalizeReason(manager.ErrMachineClosed.Error()),
994+
env.mm.RecordedApplicationFailures[env.app.Application.ID],
995+
)
996+
require.False(env.service.Ready())
997+
})
998+
896999
s.Run("EmptyEpochIndexGt0RepeatsPreviousProof", func() {
8971000
require := s.Require()
8981001
env := s.setupOneApp()
@@ -1957,13 +2060,16 @@ func newMockInstance(impl *MockMachineImpl) *MockMachineInstance {
19572060
// ------------------------------------------------------------------------------------------------
19582061

19592062
type MockMachineManager struct {
1960-
Map map[int64]*MockMachineInstance
1961-
UpdateMachinesError error
2063+
Map map[int64]*MockMachineInstance
2064+
UpdateMachinesError error
2065+
PendingApplicationFailures bool
2066+
RecordedApplicationFailures map[int64]string
19622067
}
19632068

19642069
func newMockMachineManager() *MockMachineManager {
19652070
return &MockMachineManager{
1966-
Map: map[int64]*MockMachineInstance{},
2071+
Map: map[int64]*MockMachineInstance{},
2072+
RecordedApplicationFailures: map[int64]string{},
19672073
}
19682074
}
19692075

@@ -1979,6 +2085,14 @@ func (mock *MockMachineManager) UpdateMachines(ctx context.Context) error {
19792085
return mock.UpdateMachinesError
19802086
}
19812087

2088+
func (mock *MockMachineManager) FenceApplicationFailure(app *Application, reason string) {
2089+
if mock.RecordedApplicationFailures == nil {
2090+
mock.RecordedApplicationFailures = map[int64]string{}
2091+
}
2092+
mock.RecordedApplicationFailures[app.ID] = appstatus.NormalizeReason(reason)
2093+
mock.PendingApplicationFailures = true
2094+
}
2095+
19822096
func (mock *MockMachineManager) Applications() []*Application {
19832097
apps := make([]*Application, 0, len(mock.Map))
19842098
for _, v := range mock.Map {
@@ -1993,6 +2107,10 @@ func (mock *MockMachineManager) HasMachine(appID int64) bool {
19932107
return exists
19942108
}
19952109

2110+
func (mock *MockMachineManager) HasPendingApplicationFailures() bool {
2111+
return mock.PendingApplicationFailures
2112+
}
2113+
19962114
func (mock *MockMachineManager) Close() error {
19972115
return nil
19982116
}

internal/advancer/determinism_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ func testDeterminismCallerDeadlines(
229229
require.Equal(t, uint64(1), harness.instance.ProcessedInputs())
230230
require.Zero(t, repo.ApplicationStatusUpdates,
231231
"the expired context prevents the immediate FAILED status write")
232+
require.True(t, harness.provider.HasPendingApplicationFailures())
233+
require.Equal(
234+
t,
235+
context.DeadlineExceeded.Error(),
236+
harness.provider.failureReason(harness.app.ID),
237+
)
232238
require.Equal(t, predecessor, harness.factory.base.snapshot())
233239
require.True(t, harness.factory.base.isClosed(),
234240
"a timed-out advance must close the changed live machine")
@@ -894,7 +900,7 @@ func (p *determinismMachineProvider) Applications() []*model.Application {
894900

895901
func (p *determinismMachineProvider) UpdateMachines(context.Context) error { return nil }
896902

897-
func (p *determinismMachineProvider) RecordApplicationFailure(app *model.Application, reason string) {
903+
func (p *determinismMachineProvider) FenceApplicationFailure(app *model.Application, reason string) {
898904
p.mu.Lock()
899905
defer p.mu.Unlock()
900906
if p.failures == nil {

internal/advancer/service.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,13 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) {
113113
}
114114

115115
// Service interface implementation
116-
func (s *Service) Alive() bool { return true }
117-
func (s *Service) Ready() bool { return true }
116+
func (s *Service) Alive() bool { return true }
117+
func (s *Service) Ready() bool {
118+
// This is a local fail-closed signal while application-failure durability
119+
// is unresolved. It cannot atomically revoke work already selected by a
120+
// separate process before that durable status becomes visible.
121+
return s.machineManager != nil && !s.machineManager.HasPendingApplicationFailures()
122+
}
118123
func (s *Service) Reload() []error { return nil }
119124
func (s *Service) Tick() []error {
120125
hadWork, err := s.Step(s.Context)

internal/manager/types.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,17 @@ type MachineProvider interface {
4444
// UpdateMachines refreshes the list of machines
4545
UpdateMachines(ctx context.Context) error
4646

47+
// FenceApplicationFailure fences an application whose initial FAILED
48+
// status write could not be confirmed. It queues a later durability retry
49+
// without duplicating the initial repository write.
50+
FenceApplicationFailure(app *Application, reason string)
51+
4752
// HasMachine checks if a machine exists for the given application ID
4853
HasMachine(appID int64) bool
4954

55+
// HasPendingApplicationFailures reports an unresolved durable-status fence.
56+
HasPendingApplicationFailures() bool
57+
5058
// Close shuts down all machine instances and releases resources
5159
Close() error
5260
}

0 commit comments

Comments
 (0)