diff --git a/internal/advancer/advancer_test.go b/internal/advancer/advancer_test.go index 0fa2a8fff..d8987ca17 100644 --- a/internal/advancer/advancer_test.go +++ b/internal/advancer/advancer_test.go @@ -519,7 +519,7 @@ func (s *AdvancerSuite) TestProcess() { terminal := randomAdvanceResult(1) terminal.Status = status terminal.Outputs = nil - terminal.Reports = nil + terminal.Reports = [][]byte{[]byte("terminal report")} if status == InputCompletionStatus_Exception { terminal.ExceptionData = []byte("guest exception") } @@ -537,6 +537,7 @@ func (s *AdvancerSuite) TestProcess() { require.True(stopped) require.Len(env.repo.StoredResults, 2) require.Equal(status, env.repo.StoredResults[1].Status) + require.Equal(terminal.Reports, env.repo.StoredResults[1].Reports) require.Equal([]byte("must not execute"), inputs[2].RawData) }) } @@ -547,7 +548,7 @@ func (s *AdvancerSuite) TestProcess() { rejected := randomAdvanceResult(0) rejected.Status = InputCompletionStatus_Rejected rejected.Outputs = nil - rejected.Reports = nil + rejected.Reports = [][]byte{[]byte("rejection report")} accepted := randomAdvanceResult(1) processed, stopped, err := env.service.processInputs( @@ -560,6 +561,7 @@ func (s *AdvancerSuite) TestProcess() { require.Equal(uint64(2), processed) require.False(stopped) require.Len(env.repo.StoredResults, 2) + require.Equal(rejected.Reports, env.repo.StoredResults[0].Reports) }) s.Run("Noop", func() { @@ -1581,7 +1583,7 @@ func (s *AdvancerSuite) TestTerminalInputStopsEpochAndFutureTicks() { terminal := randomAdvanceResult(1) terminal.Status = InputCompletionStatus_MachineHalted terminal.Outputs = nil - terminal.Reports = nil + terminal.Reports = [][]byte{[]byte("terminal report")} address := env.app.Application.IApplicationAddress env.repo.GetEpochsReturn = map[common.Address][]*Epoch{ address: {{ @@ -1603,6 +1605,7 @@ func (s *AdvancerSuite) TestTerminalInputStopsEpochAndFutureTicks() { require.NoError(err) require.False(hadWork) require.Len(env.repo.StoredResults, 2) + require.Equal(terminal.Reports, env.repo.StoredResults[1].Reports) require.Zero(env.repo.EpochInputsProcessedCount, "a terminal epoch must remain closed because it has no accepted terminal state") diff --git a/internal/advancer/determinism_test.go b/internal/advancer/determinism_test.go index f7effe6ea..656a3a0dc 100644 --- a/internal/advancer/determinism_test.go +++ b/internal/advancer/determinism_test.go @@ -355,17 +355,16 @@ func requireDeterminismTarget( require.Len(t, target.PeriodicStateHashes, 2, "a PRT result must retain its periodic state hashes") require.Equal(t, machine.InputEntryCapacity-uint64(len(target.PeriodicStateHashes)), target.PaddingRepetitions) + require.Equal(t, [][]byte{append([]byte("report:"), targetPayload...)}, target.Reports) if wantStatus == model.InputCompletionStatus_Accepted { require.True(t, target.IsComplete()) require.Equal(t, [][]byte{append([]byte("output:"), targetPayload...)}, target.Outputs) - require.Equal(t, [][]byte{append([]byte("report:"), targetPayload...)}, target.Reports) require.NotEqual(t, prefix.MachineHash, target.MachineHash) require.NotEqual(t, prefix.TxBufferDataBlock, target.TxBufferDataBlock) return } - require.Empty(t, target.Outputs, "effects are canonical only for accepted inputs") - require.Empty(t, target.Reports, "effects are canonical only for accepted inputs") + require.Empty(t, target.Outputs, "outputs are canonical only for accepted inputs") if wantStatus.IsTerminal() { require.True(t, target.IsComplete(), "a terminal result must preserve its actual post-run proof") @@ -823,12 +822,12 @@ func (m *determinismRuntime) Advance( hashes := []machine.Hash{firstHash, finalHash} response := &machine.AdvanceResponse{ Status: status, + Reports: []machine.Report{report}, PeriodicStateHashes: hashes, PaddingRepetitions: machine.InputEntryCapacity - uint64(len(hashes)), } if status == machine.CompletionStatusAccepted { response.Outputs = []machine.Output{output} - response.Reports = []machine.Report{report} } else if status == machine.CompletionStatusException { response.ExceptionData = append([]byte{}, input...) } diff --git a/internal/advancer/invalid_outputs_root_test.go b/internal/advancer/invalid_outputs_root_test.go index 37fdd1961..03e02af29 100644 --- a/internal/advancer/invalid_outputs_root_test.go +++ b/internal/advancer/invalid_outputs_root_test.go @@ -16,7 +16,7 @@ func (s *AdvancerSuite) TestInvalidOutputsRootStopsClaimPreparation() { terminal := randomAdvanceResult(0) terminal.Status = model.InputCompletionStatus_InvalidOutputsRoot terminal.Outputs = nil - terminal.Reports = nil + terminal.Reports = [][]byte{[]byte("invalid outputs root report")} address := env.app.Application.IApplicationAddress env.repo.GetEpochsReturn = map[common.Address][]*model.Epoch{ address: {{ @@ -36,6 +36,7 @@ func (s *AdvancerSuite) TestInvalidOutputsRootStopsClaimPreparation() { s.False(hadWork) s.Require().Len(env.repo.StoredResults, 1) s.Equal(model.InputCompletionStatus_InvalidOutputsRoot, env.repo.StoredResults[0].Status) + s.Equal(terminal.Reports, env.repo.StoredResults[0].Reports) s.Zero(env.repo.EpochInputsProcessedCount, "the epoch must not become claim work") s.Zero(env.repo.ApplicationStatusUpdates, "the status belongs to the atomic input-result write") @@ -51,7 +52,7 @@ func (s *AdvancerSuite) TestInvalidOutputsRootStoreFailureStopsService() { terminal := randomAdvanceResult(0) terminal.Status = model.InputCompletionStatus_InvalidOutputsRoot terminal.Outputs = nil - terminal.Reports = nil + terminal.Reports = [][]byte{[]byte("invalid outputs root report")} pending := newInput(env.app.Application.ID, 0, 0, marshal(terminal)) env.repo.StoreAdvanceError = errors.New("terminal result write failed") diff --git a/internal/manager/instance.go b/internal/manager/instance.go index c5df4c29f..b76ff67d5 100644 --- a/internal/manager/instance.go +++ b/internal/manager/instance.go @@ -281,6 +281,7 @@ func (m *MachineInstanceImpl) Advance( EpochIndex: epochIndex, InputIndex: index, Status: status, + Reports: advanceResp.Reports, ExceptionData: advanceResp.ExceptionData, PeriodicStateHashes: advanceResp.PeriodicStateHashes, PaddingRepetitions: advanceResp.PaddingRepetitions, @@ -306,10 +307,10 @@ func (m *MachineInstanceImpl) Advance( } result.StateProof = *postProof result.Outputs = advanceResp.Outputs - result.Reports = advanceResp.Reports adoptFork = true case model.InputCompletionStatus_Rejected: - // Rejected execution has no canonical state transition or effects. + // Rejection keeps the predecessor state and discards outputs. Reports + // remain available as diagnostics from the completed execution. result.StateProof = *prevProof case model.InputCompletionStatus_Exception, model.InputCompletionStatus_MachineHalted, diff --git a/internal/manager/instance_test.go b/internal/manager/instance_test.go index ab657795a..dd8d36cc6 100644 --- a/internal/manager/instance_test.go +++ b/internal/manager/instance_test.go @@ -404,9 +404,14 @@ func (s *MachineInstanceSuite) TestAdvance() { require.NotNil(res) require.Same(inner, instance.runtime) + preProof, proofErr := stateProofFromMachine(fork.StateProofReturn) + require.NoError(proofErr) + require.Equal(*preProof, res.StateProof) + require.Equal(int64(1), fork.CloseCalls.Load()) + require.Zero(inner.CloseCalls.Load()) require.Equal(model.InputCompletionStatus_Rejected, res.Status) require.Empty(res.Outputs) - require.Empty(res.Reports) + require.Equal(expectedReports1, res.Reports) require.Equal(newHash(1), res.TxBufferDataBlock) require.Equal(newHash(2), res.MachineHash) require.True(res.IsComplete()) @@ -444,8 +449,11 @@ func (s *MachineInstanceSuite) TestAdvance() { require.Nil(instance.runtime) require.Equal(inputStatus, res.Status) require.Equal(exceptionData, res.ExceptionData) + wantProof, proofErr := stateProofFromMachine(postProof) + require.NoError(proofErr) + require.Equal(*wantProof, res.StateProof) require.Empty(res.Outputs) - require.Empty(res.Reports) + require.Equal(expectedReports1, res.Reports) require.Equal(newHash(1), res.TxBufferDataBlock) require.Equal(newHash(2), res.MachineHash) require.True(res.IsComplete()) diff --git a/internal/manager/invalid_outputs_root_test.go b/internal/manager/invalid_outputs_root_test.go index 2e0e5f65e..b8e8ecfb9 100644 --- a/internal/manager/invalid_outputs_root_test.go +++ b/internal/manager/invalid_outputs_root_test.go @@ -57,7 +57,7 @@ func (s *MachineInstanceSuite) TestInvalidOutputsRootPreservesFinalEvidence() { s.Equal(fork.AdvanceRemainingReturn, result.PaddingRepetitions) s.Equal(collect, result.IsDaveConsensus) s.Empty(result.Outputs) - s.Empty(result.Reports) + s.Equal(expectedReports1, result.Reports) s.Nil(result.ExceptionData) s.Equal(2, proofCalls) s.Equal(uint64(6), instance.ProcessedInputs()) diff --git a/internal/replay/compare.go b/internal/replay/compare.go index 01e7081bf..336235179 100644 --- a/internal/replay/compare.go +++ b/internal/replay/compare.go @@ -100,17 +100,14 @@ func compareRecord( if err := compareBytes("outputs", record.Outputs, actual.Outputs, contradiction); err != nil { return err } - if err := compareBytes("reports", record.Reports, actual.Reports, contradiction); err != nil { - return err - } } else { - // Effects of nonaccepted executions are diagnostics, not canonical. + // Outputs from nonaccepted executions must not be persisted. if len(record.Outputs) != 0 { return contradiction("outputs.count", 0, len(record.Outputs)) } - if len(record.Reports) != 0 { - return contradiction("reports.count", 0, len(record.Reports)) - } + } + if err := compareBytes("reports", record.Reports, actual.Reports, contradiction); err != nil { + return err } if !isPRT { diff --git a/internal/replay/compare_test.go b/internal/replay/compare_test.go index 336cfb1de..601190782 100644 --- a/internal/replay/compare_test.go +++ b/internal/replay/compare_test.go @@ -141,6 +141,8 @@ func TestCompareReplayRecordCompletionMatrix(t *testing.T) { model.InputCompletionStatus_Rejected, model.InputCompletionStatus_Exception, model.InputCompletionStatus_MachineHalted, + model.InputCompletionStatus_Overflow, + model.InputCompletionStatus_UnexpectedYield, model.InputCompletionStatus_InvalidOutputsRoot, } consensuses := []model.Consensus{ @@ -154,7 +156,8 @@ func TestCompareReplayRecordCompletionMatrix(t *testing.T) { app, record, actual := replayFixture(status, consensus) if status != model.InputCompletionStatus_Accepted { actual.Outputs = [][]byte{[]byte("noncanonical diagnostic output")} - actual.Reports = [][]byte{[]byte("noncanonical diagnostic report")} + record.Reports = [][]byte{[]byte("diagnostic report")} + actual.Reports = [][]byte{[]byte("diagnostic report")} } if consensus == model.Consensus_PRT { checkpoint := common.HexToHash("0x33") @@ -271,26 +274,22 @@ func TestCompareReplayRecordPersistedRecordValidation(t *testing.T) { ErrContradiction, ) }) - for _, effect := range []string{"output", "report"} { - t.Run("nonaccepted persisted "+effect, func(t *testing.T) { - app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) - if effect == "output" { - record.Outputs = [][]byte{[]byte("illegal")} - } else { - record.Reports = [][]byte{[]byte("illegal")} - } - // Replay diagnostics are ignored, but persisted effects are corruption. - actual.Outputs = [][]byte{[]byte("diagnostic")} - actual.Reports = [][]byte{[]byte("diagnostic")} - require.ErrorIs(t, - compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual), - ErrContradiction, - ) - }) - } - t.Run("nonaccepted replay diagnostics ignored", func(t *testing.T) { + t.Run("nonaccepted persisted output", func(t *testing.T) { + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + record.Outputs = [][]byte{[]byte("illegal")} + actual.Outputs = [][]byte{[]byte("diagnostic")} + record.Reports = [][]byte{[]byte("diagnostic")} + actual.Reports = [][]byte{[]byte("diagnostic")} + err := compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual) + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "outputs.count", detail.Field) + }) + t.Run("nonaccepted replay outputs ignored", func(t *testing.T) { app, record, actual := replayFixture(model.InputCompletionStatus_Exception, model.Consensus_Authority) actual.Outputs = [][]byte{[]byte("diagnostic")} + record.Reports = [][]byte{[]byte("diagnostic")} actual.Reports = [][]byte{[]byte("diagnostic")} require.NoError(t, compareRecord(app.Name, app.ID, app.IsDaveConsensus(), repository.ReplayVerificationFull, record, actual)) }) diff --git a/internal/replay/report_test.go b/internal/replay/report_test.go new file mode 100644 index 000000000..d8949b692 --- /dev/null +++ b/internal/replay/report_test.go @@ -0,0 +1,96 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package replay + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func TestCompareRecordNonacceptedReports(t *testing.T) { + t.Parallel() + for _, status := range model.InputCompletionStatusAllValues { + if status == model.InputCompletionStatus_Accepted || !status.IsCompleted() { + continue + } + t.Run(status.String(), func(t *testing.T) { + for _, test := range []struct { + name string + reports [][]byte + field string + }{ + {"matching", [][]byte{[]byte("first"), []byte("second")}, ""}, + {"payload", [][]byte{[]byte("changed"), []byte("second")}, "reports[0]"}, + {"count", [][]byte{[]byte("first")}, "reports.count"}, + {"order", [][]byte{[]byte("second"), []byte("first")}, "reports[0]"}, + } { + t.Run(test.name, func(t *testing.T) { + app, record, actual := replayFixture(status, model.Consensus_Authority) + record.Reports = [][]byte{[]byte("first"), []byte("second")} + actual.Reports = test.reports + err := compareRecord(app.Name, app.ID, false, repository.ReplayVerificationFull, record, actual) + if test.field == "" { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, test.field, detail.Field) + }) + } + }) + } +} + +func TestCompareRecordLegacyReports(t *testing.T) { + t.Parallel() + app, record, actual := replayFixture(model.InputCompletionStatus_Rejected, model.Consensus_Authority) + actual.Reports = [][]byte{[]byte("historically discarded")} + require.NoError(t, compareRecord(app.Name, app.ID, false, repository.ReplayVerificationCanonical, record, actual)) + var detail *ContradictionError + err := compareRecord(app.Name, app.ID, false, repository.ReplayVerificationFull, record, actual) + require.ErrorAs(t, err, &detail) + require.Equal(t, "reports.count", detail.Field) + require.Equal(t, "0", detail.Expected) + require.Equal(t, "1", detail.Actual) +} + +func TestRunLegacyReports(t *testing.T) { + t.Parallel() + for _, level := range []repository.ReplayVerificationLevel{ + repository.ReplayVerificationCanonical, repository.ReplayVerificationFull, + } { + t.Run(level.String(), func(t *testing.T) { + record := replayRecords(1)[0] + record.Input.Status = model.InputCompletionStatus_Rejected + source := &fakeSource{ + summary: model.ReplaySummary{ApplicationID: 7, ProcessedInputs: 1, Consensus: model.Consensus_Authority}, + records: []*model.ReplayRecord{record}, + } + executor := &fakeExecutor{ + statuses: map[uint64]model.InputCompletionStatus{0: model.InputCompletionStatus_Rejected}, + reports: map[uint64][][]byte{0: {[]byte("historically discarded")}}, + } + opts := replayOptions(model.Consensus_Authority, 0, 1) + opts.Verification = level + result, err := Run(context.Background(), source, executor, opts) + if level == repository.ReplayVerificationCanonical { + require.NoError(t, err) + require.Equal(t, uint64(1), result.ReplayedInputs) + } else { + require.ErrorIs(t, err, ErrContradiction) + var detail *ContradictionError + require.ErrorAs(t, err, &detail) + require.Equal(t, "reports.count", detail.Field) + } + require.Nil(t, record.Reports, "replay must leave the source record unchanged") + }) + } +} diff --git a/internal/replay/run_test.go b/internal/replay/run_test.go index b20579927..0d66f3128 100644 --- a/internal/replay/run_test.go +++ b/internal/replay/run_test.go @@ -68,6 +68,7 @@ type fakeExecutor struct { wrongResultPos bool fullPRTResult bool statuses map[uint64]model.InputCompletionStatus + reports map[uint64][][]byte } func (executor *fakeExecutor) ProcessedInputs() uint64 { return executor.processed } @@ -97,6 +98,7 @@ func (executor *fakeExecutor) Advance( EpochIndex: epochIndex, InputIndex: resultIndex, Status: model.InputCompletionStatus_Accepted, + Reports: executor.reports[inputIndex], StateProof: model.StateProof{ MachineHash: common.BigToHash(newBig(inputIndex + 1)), TxBufferDataBlock: common.BigToHash(newBig(inputIndex + 100)), diff --git a/internal/repository/postgres/bulk.go b/internal/repository/postgres/bulk.go index 16ce1ebc2..7687a151a 100644 --- a/internal/repository/postgres/bulk.go +++ b/internal/repository/postgres/bulk.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "math" + "net" + "os" "github.com/ethereum/go-ethereum/common" "github.com/go-jet/jet/v2/postgres" @@ -66,12 +68,8 @@ func getReportNextIndex( postgres.Float(1).ADD(postgres.MAXf(table.Report.Index)), postgres.Float(0), ), - ).FROM( - table.Report.INNER_JOIN(table.Input, table.Input.EpochApplicationID.EQ(table.Report.InputEpochApplicationID). - AND(table.Input.Index.EQ(table.Report.InputIndex))), - ).WHERE( - table.Report.InputEpochApplicationID.EQ(postgres.Int64(appID)). - AND(table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()))), + ).FROM(table.Report).WHERE( + table.Report.InputEpochApplicationID.EQ(postgres.Int64(appID)), ) queryStr, args := query.Sql() @@ -126,27 +124,9 @@ func insertOutputs( return err } - stmt := table.Output.INSERT( - table.Output.InputEpochApplicationID, - table.Output.InputIndex, - table.Output.Index, - table.Output.RawData, - ) - for i, data := range dataArray { - stmt = stmt.VALUES( - appID, - inputIndex, - nextIndex+uint64(i), - data, - ) - } - - sqlStr, args := stmt.Sql() - _, err = tx.Exec(ctx, sqlStr, args...) - if err != nil { - return err - } - return nil + return copyAdvanceData(ctx, tx, table.Output, postgres.ColumnList{ + table.Output.InputEpochApplicationID, table.Output.InputIndex, table.Output.Index, table.Output.RawData, + }, appID, inputIndex, nextIndex, dataArray) } func insertReports( @@ -165,27 +145,71 @@ func insertReports( return err } - stmt := table.Report.INSERT( - table.Report.InputEpochApplicationID, - table.Report.InputIndex, - table.Report.Index, - table.Report.RawData, + return copyAdvanceData(ctx, tx, table.Report, postgres.ColumnList{ + table.Report.InputEpochApplicationID, table.Report.InputIndex, table.Report.Index, table.Report.RawData, + }, appID, inputIndex, nextIndex, dataArray) +} + +// copyAdvanceData streams rows within the result transaction, avoiding the +// extended protocol's parameter-count and aggregate Bind-message size limits. +func copyAdvanceData( + ctx context.Context, + tx pgx.Tx, + destination postgres.Table, + columns postgres.ColumnList, + appID int64, + inputIndex uint64, + nextIndex uint64, + dataArray [][]byte, +) error { + columnNames := make([]string, len(columns)) + for i, column := range columns { + columnNames[i] = column.Name() + } + tableName := destination.TableName() + copied, err := tx.CopyFrom( + ctx, + pgx.Identifier{destination.SchemaName(), tableName}, + columnNames, + pgx.CopyFromSlice(len(dataArray), func(i int) ([]any, error) { + index := nextIndex + uint64(i) //nolint:gosec // CopyFromSlice supplies nonnegative row positions. + return []any{appID, inputIndex, index, dataArray[i]}, nil + }), ) - for i, data := range dataArray { - stmt = stmt.VALUES( - appID, - inputIndex, - nextIndex+uint64(i), - data, - ) + if err != nil { + return fmt.Errorf("failed to copy %s rows for application %d input %d: %w", + tableName, appID, inputIndex, normalizeCopyError(ctx, err)) + } + if copied != int64(len(dataArray)) { + return fmt.Errorf("copied %d %s rows for application %d input %d, expected %d", + copied, tableName, appID, inputIndex, len(dataArray)) } + return nil +} - sqlStr, args := stmt.Sql() - _, err = tx.Exec(ctx, sqlStr, args...) - if err != nil { +// COPY's concurrent reader and writer can report a locally closed socket after +// cancellation sets its deadline. Normalize only local socket closure or deadline on a +// canceled context; server errors, peer disconnections, and mixed causes remain failures. +func normalizeCopyError(ctx context.Context, err error) error { + if ctx.Err() != context.Canceled || err == nil { return err } - return nil + cause := err + for { + if _, mixed := cause.(interface{ Unwrap() []error }); mixed { + return err + } + wrapper, ok := cause.(interface{ Unwrap() error }) + if !ok || wrapper.Unwrap() == nil { + break + } + cause = wrapper.Unwrap() + } + if errors.Is(cause, net.ErrClosed) || errors.Is(cause, os.ErrDeadlineExceeded) { + // Preserve the transport diagnostic while exposing cancellation as the cause. + return fmt.Errorf("COPY interrupted (%v): %w", err, context.Canceled) + } + return err } func insertStateHashes( @@ -239,7 +263,7 @@ func insertStateHashes( source, ) if err != nil { - return err + return normalizeCopyError(ctx, err) } expectedCopied := int64(rowCount) //nolint:gosec // Span validation bounds rowCount far below MaxInt64. if copied != expectedCopied { @@ -563,9 +587,8 @@ func (r *PostgresRepository) StoreAdvanceResult( if res.InputIndex == math.MaxUint64 { return errors.New("cannot store an advance result at the maximum input index") } - if res.Status != model.InputCompletionStatus_Accepted && - (len(res.Outputs) != 0 || len(res.Reports) != 0) { - return fmt.Errorf("advance result with status %q must not contain outputs or reports", res.Status) + if res.Status != model.InputCompletionStatus_Accepted && len(res.Outputs) != 0 { + return fmt.Errorf("advance result with status %q must not contain outputs", res.Status) } if !res.IsComplete() { return repository.ErrInvalidStateProof @@ -590,11 +613,10 @@ func (r *PostgresRepository) StoreAdvanceResult( if err != nil { return err } - - err = insertReports(ctx, tx, appID, res.InputIndex, res.Reports) - if err != nil { - return err - } + } + // Reports are diagnostics for every completed input, independent of outputs. + if err := insertReports(ctx, tx, appID, res.InputIndex, res.Reports); err != nil { + return err } if res.IsDaveConsensus { diff --git a/internal/repository/postgres/bulk_test.go b/internal/repository/postgres/bulk_test.go new file mode 100644 index 000000000..056ed8277 --- /dev/null +++ b/internal/repository/postgres/bulk_test.go @@ -0,0 +1,49 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestInsertAdvanceDataQualifiesPublicSchema(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := NewPostgresRepository(ctx, endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + seed := repotest.Seed(ctx, t, repo) + tx, err := repo.(*PostgresRepository).db.Begin(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = tx.Rollback(context.Background()) }) + _, err = tx.Exec(ctx, ` + CREATE TEMP TABLE output (LIKE public.output INCLUDING ALL) ON COMMIT DROP; + CREATE TEMP TABLE report (LIKE public.report INCLUDING ALL) ON COMMIT DROP; + SET LOCAL search_path TO pg_temp, public`) + require.NoError(t, err) + payloads := [][]byte{[]byte("public evidence")} + require.NoError(t, insertOutputs(ctx, tx, seed.App.ID, 0, payloads)) + require.NoError(t, insertReports(ctx, tx, seed.App.ID, 0, payloads)) + for _, tableName := range []string{table.Output.TableName(), table.Report.TableName()} { + t.Run(tableName, func(t *testing.T) { + var publicCount, shadowCount uint64 + require.NoError(t, tx.QueryRow(ctx, "SELECT count(*) FROM public."+tableName).Scan(&publicCount)) + require.NoError(t, tx.QueryRow(ctx, "SELECT count(*) FROM pg_temp."+tableName).Scan(&shadowCount)) + require.Equal(t, uint64(1), publicCount) + require.Zero(t, shadowCount) + }) + } + require.NoError(t, tx.Commit(ctx)) +} diff --git a/internal/repository/postgres/copy_cancellation_test.go b/internal/repository/postgres/copy_cancellation_test.go new file mode 100644 index 000000000..44cbed3a0 --- /dev/null +++ b/internal/repository/postgres/copy_cancellation_test.go @@ -0,0 +1,230 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package postgres + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "syscall" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/errutil" + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository/postgres/db/rollupsdb/public/table" + "github.com/cartesi/rollups-node/internal/repository/repotest" + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestNormalizeCopyError(t *testing.T) { + t.Parallel() + canceled, cancel := context.WithCancel(t.Context()) + cancel() + deadline, stopDeadline := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer stopDeadline() + const network = "tcp" + closed := &net.OpError{Op: "read", Net: network, Err: net.ErrClosed} + timeout := &net.OpError{Op: "write", Net: network, Err: os.ErrDeadlineExceeded} + constraint := &pgconn.PgError{Code: "23502", Message: "null raw_data"} + for _, tc := range []struct { + name string + ctx context.Context + err error + normalized bool + }{ + {"success", canceled, nil, false}, + {"cancellation", canceled, context.Canceled, false}, + {"closed socket during shutdown", canceled, closed, true}, + {"wrapped closed socket", canceled, fmt.Errorf("copy: %w", closed), true}, + {"timeout during shutdown", canceled, timeout, true}, + {"closed socket on live context", t.Context(), closed, false}, + {"timeout on live context", t.Context(), timeout, false}, + {"closed socket on deadline", deadline, closed, false}, + {"context deadline during shutdown", canceled, context.DeadlineExceeded, false}, + {"server error during shutdown", canceled, constraint, false}, + {"driver connection already closed", canceled, pgconn.ErrConnClosed, false}, + {"encoding error during shutdown", canceled, errors.New("cannot encode row"), false}, + {"peer EOF during shutdown", canceled, io.EOF, false}, + {"peer reset during shutdown", canceled, &net.OpError{Op: "read", Net: network, Err: syscall.ECONNRESET}, false}, + {"peer timeout during shutdown", canceled, &net.OpError{Op: "write", Net: network, Err: syscall.ETIMEDOUT}, false}, + {"mixed server error", canceled, errors.Join(closed, constraint), false}, + {"mixed peer error", canceled, fmt.Errorf("copy: %w", errors.Join(closed, io.EOF)), false}, + {"mixed cancellation", canceled, errors.Join(closed, context.Canceled), false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeCopyError(tc.ctx, tc.err) + if !tc.normalized { + require.Equal(t, tc.err, got, "independent failures must keep their causes") + return + } + require.ErrorIs(t, got, context.Canceled) + require.True(t, errutil.IsOnlyCancellation(got)) + require.ErrorContains(t, got, tc.err.Error(), "keep the transport diagnostic") + }) + } +} + +func TestAdvanceCopyCancellationCause(t *testing.T) { + for _, tc := range []struct { + name string + insert func(context.Context, pgx.Tx) error + }{ + {table.Output.TableName(), func(ctx context.Context, tx pgx.Tx) error { + return insertOutputs(ctx, tx, 1, 0, [][]byte{[]byte("output")}) + }}, + {table.Report.TableName(), func(ctx context.Context, tx pgx.Tx) error { + return insertReports(ctx, tx, 1, 0, [][]byte{[]byte("report")}) + }}, + {table.StateHashes.TableName(), func(ctx context.Context, tx pgx.Tx) error { + return insertStateHashes(ctx, tx, 1, 0, 0, nil, repotest.UniqueHash(), model.InputHashCollectionCapacity) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + err := tc.insert(ctx, &canceledCopyTx{cancel: cancel}) + require.ErrorIs(t, err, context.Canceled) + require.True(t, errutil.IsOnlyCancellation(err)) + require.ErrorContains(t, err, net.ErrClosed.Error()) + }) + } +} + +// canceledCopyTx returns the closed-socket variant of a canceled COPY, +// independently of pgx's reader/writer scheduling in the live database test. +type canceledCopyTx struct { + pgx.Tx + cancel context.CancelFunc +} + +func (tx *canceledCopyTx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) { + tx.cancel() + return 0, fmt.Errorf("COPY socket: %w", net.ErrClosed) +} + +func (tx *canceledCopyTx) QueryRow(context.Context, string, ...any) pgx.Row { + return copyIndexRow{} +} + +type copyIndexRow struct{} + +func (copyIndexRow) Scan(dest ...any) error { + *dest[0].(*uint64) = 0 + return nil +} + +func TestStoreAdvanceResultCancellationDuringCopy(t *testing.T) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + t.Skipf("Skipping: %v", err) + } + for _, tableName := range []string{table.Output.TableName(), table.Report.TableName()} { + t.Run(tableName, func(t *testing.T) { + require.NoError(t, db.SetupTestPostgres(endpoint)) + ctx := t.Context() + repo, err := NewPostgresRepository(ctx, endpoint, 1, 0) + require.NoError(t, err) + t.Cleanup(repo.Close) + control, err := pgx.Connect(ctx, endpoint) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, control.Close(context.Background())) }) + seed := repotest.Seed(ctx, t, repo) + const copyLock = 739107 + _, err = control.Exec(ctx, `CREATE FUNCTION pause_advance_copy() RETURNS trigger AS $$ + BEGIN + PERFORM pg_advisory_xact_lock(739107); + RETURN NEW; + END; + $$ LANGUAGE plpgsql`) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := control.Exec(cleanupCtx, "DROP FUNCTION pause_advance_copy() CASCADE") + require.NoError(t, err) + }) + _, err = control.Exec(ctx, "CREATE TRIGGER pause_advance_copy BEFORE INSERT ON "+tableName+ + " FOR EACH ROW EXECUTE FUNCTION pause_advance_copy()") + require.NoError(t, err) + _, err = control.Exec(ctx, "SELECT pg_advisory_lock($1)", copyLock) + require.NoError(t, err) + // The trigger pauses COPY at its first row so cancellation happens before Commit. + const payloadCount = 16 + payload := bytes.Repeat([]byte("x"), 2*1024*1024) + payloads := make([][]byte, payloadCount) + for i := range payloads { + payloads[i] = payload + } + result := &model.AdvanceResult{ + Status: model.InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("output before reports")}, + Reports: payloads, + StateProof: *repotest.DummyStateProof(), + } + if tableName == table.Output.TableName() { + result.Outputs, result.Reports = result.Reports, result.Outputs + } + writeCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + finished := make(chan struct{}) + t.Cleanup(func() { + cancel() + cleanupCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + // Release the backend's trigger wait before dropping its function. + _, err := control.Exec(cleanupCtx, "SELECT pg_advisory_unlock($1)", copyLock) + require.NoError(t, err) + select { + case <-finished: + case <-cleanupCtx.Done(): + t.Error("COPY goroutine did not stop during cleanup") + } + }) + go func() { + defer close(finished) + done <- repo.StoreAdvanceResult(writeCtx, seed.App.ID, result) + }() + require.Eventually(t, func() bool { + var paused bool + err := control.QueryRow(ctx, `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE datname = current_database() AND query LIKE 'copy %' AND wait_event = 'advisory' + )`).Scan(&paused) + return err == nil && paused + }, 5*time.Second, 10*time.Millisecond, "COPY must reach the paused trigger") + cancel() + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + require.True(t, errutil.IsOnlyCancellation(err), "%v", err) + case <-time.After(5 * time.Second): + t.Fatal("canceled COPY did not return") + } + _, err = control.Exec(ctx, "SELECT pg_advisory_unlock($1)", copyLock) + require.NoError(t, err) + for _, tableName := range []string{table.Output.TableName(), table.Report.TableName()} { + var rows uint64 + require.NoError(t, control.QueryRow(ctx, "SELECT count(*) FROM "+tableName).Scan(&rows)) + require.Zero(t, rows) + } + input, err := repo.GetInput(ctx, seed.App.Name, 0) + require.NoError(t, err) + require.Equal(t, model.InputCompletionStatus_None, input.Status) + app, err := repo.GetApplication(ctx, seed.App.Name) + require.NoError(t, err) + require.Zero(t, app.ProcessedInputs) + // A fresh connection can persist the result after the aborted transaction. + require.NoError(t, repo.StoreAdvanceResult(ctx, seed.App.ID, result)) + }) + } +} diff --git a/internal/repository/postgres/replay_source_test.go b/internal/repository/postgres/replay_source_test.go index 7119c888d..14734ea3f 100644 --- a/internal/repository/postgres/replay_source_test.go +++ b/internal/repository/postgres/replay_source_test.go @@ -59,6 +59,7 @@ func TestPostgresReplayVerificationLevels(t *testing.T) { EpochIndex: 0, InputIndex: 1, Status: model.InputCompletionStatus_Rejected, + Reports: [][]byte{[]byte("rejection report")}, StateProof: *repotest.DummyStateProof(), })) @@ -99,6 +100,8 @@ func TestPostgresReplayVerificationLevels(t *testing.T) { require.NoError(t, err) require.Equal(t, [][]byte{[]byte("output")}, fullPage[0].Outputs) require.Equal(t, [][]byte{[]byte("report")}, fullPage[0].Reports) + require.Empty(t, fullPage[1].Outputs) + require.Equal(t, [][]byte{[]byte("rejection report")}, fullPage[1].Reports) // Poison only full evidence. Canonical reconstruction remains independent // of child evidence, while a full page exposes it to replay comparison. diff --git a/internal/repository/postgres/report.go b/internal/repository/postgres/report.go index b011e57ff..cf509f7d6 100644 --- a/internal/repository/postgres/report.go +++ b/internal/repository/postgres/report.go @@ -102,7 +102,6 @@ func (r *PostgresRepository) ListReports( if f.EpochIndex != nil { conditions = append(conditions, table.Input.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) - conditions = append(conditions, table.Input.Status.EQ(postgres.NewEnumValue(model.InputCompletionStatus_Accepted.String()))) } tx, err := beginReadTx(ctx, r.db) diff --git a/internal/repository/replay.go b/internal/repository/replay.go index d66c65ee6..bb726cb69 100644 --- a/internal/repository/replay.go +++ b/internal/repository/replay.go @@ -32,7 +32,10 @@ const ( // roots. It is the inexpensive default for normal machine reconstruction. ReplayVerificationCanonical ReplayVerificationLevel = iota - // ReplayVerificationFull additionally reads accepted outputs and reports. + // ReplayVerificationFull additionally reads outputs of accepted inputs and + // reports of every completed input. Missing reports fail Full verification + // with a reports.count contradiction and are never backfilled. Canonical + // reconstruction does not compare reports and remains compatible. // For PRT applications, it also reads the compressed per-input state-hash // collections used to reconstruct and verify the epoch computation hash. // Outputs and reports are audit evidence and do not participate in that hash. diff --git a/internal/repository/repotest/advance_evidence_test_cases.go b/internal/repository/repotest/advance_evidence_test_cases.go new file mode 100644 index 000000000..2b2bd5bf1 --- /dev/null +++ b/internal/repository/repotest/advance_evidence_test_cases.go @@ -0,0 +1,137 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "bytes" + "fmt" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *BulkOperationsSuite) TestStoreAdvanceResultLargeEvidenceSet() { + // Cover the extended protocol's parameter boundary and the machine's row limit. + for _, rowCount := range []uint64{16384, 65536} { + s.Run(fmt.Sprintf("Accepted%d", rowCount), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + outputPayloads := make([][]byte, rowCount) + reportPayloads := make([][]byte, rowCount) + for i := range outputPayloads { + outputPayloads[i] = []byte(fmt.Sprintf("output-%d", i)) + reportPayloads[i] = []byte(fmt.Sprintf("report-%d", i)) + } + s.storeAdvanceResult(seed.App.ID, 0, 0, outputPayloads, reportPayloads) + + outputs, total, err := s.Repo.ListOutputs(s.Ctx, seed.App.Name, + repository.OutputFilter{}, repository.Pagination{Limit: rowCount}, false) + s.Require().NoError(err) + s.Equal(rowCount, total) + s.Require().Equal(rowCount, uint64(len(outputs))) + for i, output := range outputs { + s.Equal(uint64(i), output.Index) + s.Zero(output.InputIndex) + s.Equal(outputPayloads[i], output.RawData) + } + + reports, total, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: rowCount}, false) + s.Require().NoError(err) + s.Equal(rowCount, total) + s.Require().Equal(rowCount, uint64(len(reports))) + for i, report := range reports { + s.Equal(uint64(i), report.Index) + s.Zero(report.InputIndex) + s.Equal(reportPayloads[i], report.RawData) + } + }) + } +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultEvidencePayloads() { + s.Run("LargeAndEmpty", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + const payloadSize = 2 * 1024 * 1024 + payloads := [][]byte{bytes.Repeat([]byte("x"), payloadSize), {}, []byte("tail")} + s.storeAdvanceResult(seed.App.ID, 0, 0, payloads, payloads) + + for i, payload := range payloads { + output, err := s.Repo.GetOutput(s.Ctx, seed.App.Name, uint64(i)) + s.Require().NoError(err) + s.Require().NotNil(output) + s.Equal(payload, output.RawData) + s.Nil(output.Hash) + s.Nil(output.OutputHashesSiblings) + s.Nil(output.ExecutionTransactionHash) + s.False(output.CreatedAt.IsZero()) + s.True(output.CreatedAt.Equal(output.UpdatedAt)) + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, uint64(i)) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(payload, report.RawData) + s.False(report.CreatedAt.IsZero()) + s.True(report.CreatedAt.Equal(report.UpdatedAt)) + } + }) +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultRollsBackEvidence() { + for _, kind := range []string{"output", "report"} { + s.Run(kind, func() { s.checkEvidenceRollback(kind) }) + } +} + +func (s *BulkOperationsSuite) checkEvidenceRollback(kind string) { + seed := Seed(s.Ctx, s.T(), s.Repo) + const rowCount = 16384 + payloads := make([][]byte, rowCount) + for i := range payloads { + payloads[i] = []byte("valid payload") + } + // A late NULL must undo every earlier row in the result transaction. + payloads[len(payloads)-1] = nil + beforeEpoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + beforeApp, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + result := &model.AdvanceResult{ + Status: model.InputCompletionStatus_Accepted, + Outputs: [][]byte{[]byte("must roll back")}, + Reports: payloads, + StateProof: *DummyStateProof(), + } + if kind == "output" { + result.Outputs, result.Reports = result.Reports, result.Outputs + } + err = s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, result) + var constraint *pgconn.PgError + s.Require().ErrorAs(err, &constraint) + s.Equal("23502", constraint.Code) + s.Equal("raw_data", constraint.ColumnName) + s.Contains(err.Error(), "failed to copy "+kind+" rows") + + reports, reportCountAfter, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: 1}, false) + s.Require().NoError(err) + s.Empty(reports) + s.Zero(reportCountAfter) + outputs, outputCount, err := s.Repo.ListOutputs(s.Ctx, seed.App.Name, + repository.OutputFilter{}, repository.Pagination{Limit: 1}, false) + s.Require().NoError(err) + s.Empty(outputs) + s.Zero(outputCount) + input, err := s.Repo.GetInput(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_None, input.Status) + s.Nil(input.MachineHash) + s.Nil(input.TxBufferDataBlock) + afterEpoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Equal(beforeEpoch, afterEpoch) + afterApp, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + s.Equal(beforeApp, afterApp) +} diff --git a/internal/repository/repotest/bulk_test_cases.go b/internal/repository/repotest/bulk_test_cases.go index 8556d089f..7f78bbbe0 100644 --- a/internal/repository/repotest/bulk_test_cases.go +++ b/internal/repository/repotest/bulk_test_cases.go @@ -92,6 +92,7 @@ func (s *BulkOperationsSuite) TestStoreAdvanceResult() { EpochIndex: 0, InputIndex: 0, Status: InputCompletionStatus_Rejected, + Reports: [][]byte{[]byte("rejection report")}, StateProof: *proof, } @@ -101,6 +102,12 @@ func (s *BulkOperationsSuite) TestStoreAdvanceResult() { input, err := s.Repo.GetInput(s.Ctx, seed.App.IApplicationAddress.String(), 0) s.Require().NoError(err) s.Equal(InputCompletionStatus_Rejected, input.Status) + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(result.Reports[0], report.RawData) + s.Zero(report.InputIndex) + s.Zero(report.EpochIndex) epoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) s.Require().NoError(err) s.Nil(epoch.MachineHash, "rejection must leave the pre-input epoch state unchanged") @@ -125,6 +132,7 @@ func (s *BulkOperationsSuite) TestStoreAdvanceResult() { EpochIndex: 0, InputIndex: 0, Status: status, + Reports: [][]byte{[]byte("terminal report")}, ExceptionData: exceptionData, StateProof: *proof, } @@ -135,6 +143,12 @@ func (s *BulkOperationsSuite) TestStoreAdvanceResult() { s.Require().NoError(err) s.Equal(status, input.Status) s.Equal(exceptionData, input.ExceptionData) + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(result.Reports[0], report.RawData) + s.Zero(report.InputIndex) + s.Zero(report.EpochIndex) epoch, err := s.Repo.GetEpoch(s.Ctx, seed.App.IApplicationAddress.String(), 0) s.Require().NoError(err) s.Require().NotNil(epoch.MachineHash) @@ -158,7 +172,7 @@ func (s *BulkOperationsSuite) TestStoreAdvanceResult() { }) } - s.Run("RejectsEffectsForNonacceptedInput", func() { + s.Run("RejectsOutputsForNonacceptedInput", func() { seed := Seed(s.Ctx, s.T(), s.Repo) err := s.Repo.StoreAdvanceResult(s.Ctx, seed.App.ID, &AdvanceResult{ EpochIndex: 0, @@ -166,7 +180,7 @@ func (s *BulkOperationsSuite) TestStoreAdvanceResult() { Status: InputCompletionStatus_Rejected, Outputs: [][]byte{[]byte("must-not-be-stored")}, }) - s.Require().ErrorContains(err, "must not contain outputs or reports") + s.Require().ErrorContains(err, "must not contain outputs") }) s.Run("RejectsCursorAndEpochMismatches", func() { diff --git a/internal/repository/repotest/nonaccepted_reports_test_cases.go b/internal/repository/repotest/nonaccepted_reports_test_cases.go new file mode 100644 index 000000000..9abc6123d --- /dev/null +++ b/internal/repository/repotest/nonaccepted_reports_test_cases.go @@ -0,0 +1,129 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func seedReportInputs(ctx context.Context, t *testing.T, repo repository.Repository) *model.Application { + t.Helper() + const ( + inputCount = 3 + epochEnd = 9 + scanHead = epochEnd + 1 + ) + app := NewApplicationBuilder().Create(ctx, t, repo) + epoch := NewEpochBuilder(app.ID).WithStatus(model.EpochStatus_Closed). + WithBlocks(0, epochEnd).WithInputBounds(0, inputCount).Build() + inputs := make([]*model.Input, inputCount) + for i := range inputs { + inputs[i] = NewInputBuilder().WithIndex(uint64(i)).WithBlockNumber(uint64(i) + 1).Build() + } + require.NoError(t, repo.CreateEpochsAndInputs(ctx, app.Name, + map[*model.Epoch][]*model.Input{epoch: inputs}, scanHead)) + return app +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultReportSequence() { + for _, firstStatus := range []model.InputCompletionStatus{ + model.InputCompletionStatus_Accepted, + model.InputCompletionStatus_Rejected, + } { + s.Run(firstStatus.String()+"/REJECTED/ACCEPTED", func() { + app := seedReportInputs(s.Ctx, s.T(), s.Repo) + statuses := []model.InputCompletionStatus{ + firstStatus, model.InputCompletionStatus_Rejected, model.InputCompletionStatus_Accepted, + } + for i, status := range statuses { + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, uint64(i), status, + nil, [][]byte{[]byte(fmt.Sprintf("report-%d", i))}) + } + reports, total, err := s.Repo.ListReports(s.Ctx, app.Name, + repository.ReportFilter{}, repository.Pagination{Limit: uint64(len(statuses))}, false) + s.Require().NoError(err) + s.Equal(uint64(len(statuses)), total) + s.Require().Len(reports, len(statuses)) + for i, report := range reports { + s.Equal(uint64(i), report.Index) + s.Equal(uint64(i), report.InputIndex) + s.Zero(report.EpochIndex) + s.Equal([]byte(fmt.Sprintf("report-%d", i)), report.RawData) + } + }) + } +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultLargeRejectedReportSet() { + // Cover the extended protocol's parameter boundary and the machine's report limit. + for _, count := range []uint64{16384, 65536} { + s.Run(fmt.Sprintf("%d", count), func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + payloads := make([][]byte, count) + for i := range payloads { + payloads[i] = []byte(fmt.Sprintf("rejected-report-%d", i)) + } + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, seed.App.ID, 0, 0, + model.InputCompletionStatus_Rejected, nil, payloads) + reports, total, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: count}, false) + s.Require().NoError(err) + s.Equal(count, total) + s.Require().Equal(count, uint64(len(reports))) + for i, report := range reports { + s.Equal(uint64(i), report.Index) + s.Zero(report.InputIndex) + s.Equal(payloads[i], report.RawData) + } + }) + } +} + +func (s *BulkOperationsSuite) TestStoreAdvanceResultReportsBeyondBindMessageLimit() { + if testing.Short() { + s.T().Skip("transfers more than 1 GiB of report data") + } + s.Run("Rejected", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + const ( + reportCount = 520 + payloadSize = 2 * 1024 * 1024 + ) + // Share the source payload and read back samples to bound test memory. + payload := bytes.Repeat([]byte("x"), payloadSize) + reports := make([][]byte, reportCount) + for i := range reports { + reports[i] = payload + } + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, seed.App.ID, 0, 0, + model.InputCompletionStatus_Rejected, nil, reports) + page, total, err := s.Repo.ListReports(s.Ctx, seed.App.Name, + repository.ReportFilter{}, repository.Pagination{Limit: 1}, false) + s.Require().NoError(err) + s.Equal(uint64(reportCount), total) + s.Require().Len(page, 1) + for _, index := range []uint64{0, reportCount / 2, reportCount - 1} { + report, err := s.Repo.GetReport(s.Ctx, seed.App.Name, index) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(index, report.Index) + s.Zero(report.InputIndex) + s.Equal(payload, report.RawData) + } + input, err := s.Repo.GetInput(s.Ctx, seed.App.Name, 0) + s.Require().NoError(err) + s.Equal(model.InputCompletionStatus_Rejected, input.Status) + app, err := s.Repo.GetApplication(s.Ctx, seed.App.Name) + s.Require().NoError(err) + s.Equal(uint64(1), app.ProcessedInputs) + }) +} diff --git a/internal/repository/repotest/report_filter_test_cases.go b/internal/repository/repotest/report_filter_test_cases.go new file mode 100644 index 000000000..c2230378f --- /dev/null +++ b/internal/repository/repotest/report_filter_test_cases.go @@ -0,0 +1,74 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package repotest + +import ( + "fmt" + + "github.com/cartesi/rollups-node/internal/model" + "github.com/cartesi/rollups-node/internal/repository" +) + +func (s *ReportSuite) TestListReportsCompletedStatuses() { + for _, terminal := range model.InputCompletionStatusAllValues { + if !terminal.IsTerminal() { + continue + } + s.Run(terminal.String(), func() { + app := seedReportInputs(s.Ctx, s.T(), s.Repo) + statuses := []model.InputCompletionStatus{ + model.InputCompletionStatus_Accepted, model.InputCompletionStatus_Rejected, terminal, + } + for i, status := range statuses { + StoreAdvanceResult(s.Ctx, s.T(), s.Repo, app.ID, 0, uint64(i), status, + nil, [][]byte{[]byte(fmt.Sprintf("report-%d", i))}) + } + epoch, missingEpoch, rejectedInput := uint64(0), uint64(1), uint64(1) + const allReports, rejectedAndTerminalReports, terminalReportIndex = 3, 2, 2 + for _, query := range []struct { + name string + filter repository.ReportFilter + pagination repository.Pagination + descending bool + indices []uint64 + total uint64 + }{ + {name: "unfiltered", indices: []uint64{0, 1, terminalReportIndex}, total: allReports}, + {name: "epoch", filter: repository.ReportFilter{EpochIndex: &epoch}, + indices: []uint64{0, 1, terminalReportIndex}, total: allReports}, + {name: "input", filter: repository.ReportFilter{InputIndex: &rejectedInput}, indices: []uint64{1}, total: 1}, + {name: "epoch and input", filter: repository.ReportFilter{EpochIndex: &epoch, InputIndex: &rejectedInput}, + indices: []uint64{1}, total: 1}, + {name: "epoch pagination", filter: repository.ReportFilter{EpochIndex: &epoch}, + pagination: repository.Pagination{Limit: 1, Offset: 1}, descending: true, indices: []uint64{1}, total: allReports}, + {name: "epoch and range", filter: repository.ReportFilter{ + EpochIndex: &epoch, IndexRange: &repository.Range{Start: 1, End: terminalReportIndex}, + }, descending: true, indices: []uint64{terminalReportIndex, 1}, total: rejectedAndTerminalReports}, + {name: "missing epoch", filter: repository.ReportFilter{EpochIndex: &missingEpoch}}, + } { + reports, total, err := s.Repo.ListReports(s.Ctx, app.Name, + query.filter, query.pagination, query.descending) + s.Require().NoError(err, query.name) + s.Equal(query.total, total, query.name) + s.Require().Len(reports, len(query.indices), query.name) + for i, report := range reports { + index := query.indices[i] + s.Equal(index, report.Index, query.name) + s.Equal(index, report.InputIndex, query.name) + s.Zero(report.EpochIndex, query.name) + s.Equal([]byte(fmt.Sprintf("report-%d", index)), report.RawData, query.name) + } + } + for i := range statuses { + report, err := s.Repo.GetReport(s.Ctx, app.Name, uint64(i)) + s.Require().NoError(err) + s.Require().NotNil(report) + s.Equal(uint64(i), report.Index) + s.Equal(uint64(i), report.InputIndex) + s.Zero(report.EpochIndex) + s.Equal([]byte(fmt.Sprintf("report-%d", i)), report.RawData) + } + }) + } +} diff --git a/internal/repository/repotest/report_test_cases.go b/internal/repository/repotest/report_test_cases.go index f90672dc7..5e77e4dcd 100644 --- a/internal/repository/repotest/report_test_cases.go +++ b/internal/repository/repotest/report_test_cases.go @@ -81,8 +81,6 @@ func (s *ReportSuite) TestListReports() { s.Run("FilterByEpochIndex", func() { seed := Seed(s.Ctx, s.T(), s.Repo) - // EpochIndex filter also requires input.status = ACCEPTED, - // so use StoreAdvanceResult to create the report with accepted input. result := &AdvanceResult{ EpochIndex: 0, InputIndex: 0, diff --git a/test/integration/cli_helpers_test.go b/test/integration/cli_helpers_test.go index 235f7fa63..67378fd71 100644 --- a/test/integration/cli_helpers_test.go +++ b/test/integration/cli_helpers_test.go @@ -257,8 +257,9 @@ func readOutput(ctx context.Context, appName string, index uint64) (*api.Decoded } // readReports lists all reports for the application. -func readReports(ctx context.Context, appName string) (*api.ListResponse[model.Report], error) { - out, err := runCLI(ctx, "read", "reports", appName) +func readReports(ctx context.Context, appName string, args ...string) (*api.ListResponse[model.Report], error) { + cliArgs := append([]string{"read", "reports", appName}, args...) + out, err := runCLI(ctx, cliArgs...) if err != nil { return nil, err } @@ -269,6 +270,19 @@ func readReports(ctx context.Context, appName string) (*api.ListResponse[model.R return &resp, nil } +func readReport(ctx context.Context, appName string, index uint64, args ...string) (*model.Report, error) { + cliArgs := append([]string{"read", "reports", appName, strconv.FormatUint(index, 10)}, args...) + out, err := runCLI(ctx, cliArgs...) + if err != nil { + return nil, err + } + var resp api.SingleResponse[model.Report] + if err := json.Unmarshal([]byte(out), &resp); err != nil { + return nil, fmt.Errorf("parse report: %w", err) + } + return &resp.Data, nil +} + // readEpoch reads a single epoch by index. func readEpoch(ctx context.Context, appName string, epochIndex uint64) (*model.Epoch, error) { out, err := runCLI(ctx, "read", "epochs", appName, strconv.FormatUint(epochIndex, 10)) diff --git a/test/integration/echo_authority_test.go b/test/integration/echo_authority_test.go index 4989a4546..9dbee823f 100644 --- a/test/integration/echo_authority_test.go +++ b/test/integration/echo_authority_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/stretchr/testify/suite" ) @@ -97,6 +98,7 @@ func (s *EchoAuthoritySuite) TestInspect() { s.Require().Equal("Accepted", result.Status) s.Require().Len(result.Reports, 1, "echo-dapp should return 1 report") + s.Equal(hexutil.Encode([]byte("hello")), result.Reports[0].Payload) s.T().Logf("Inspect returned status=%s reports=%d", result.Status, len(result.Reports)) s.T().Log("=== Inspect happy path complete ===") } diff --git a/test/integration/lifecycle_test.go b/test/integration/lifecycle_test.go index fe2db133e..63caa2b85 100644 --- a/test/integration/lifecycle_test.go +++ b/test/integration/lifecycle_test.go @@ -14,6 +14,7 @@ package integration import ( "context" "fmt" + "strconv" "testing" "time" @@ -41,7 +42,7 @@ const ( echoOutputsPerInput = 3 // Voucher + DelegateCallVoucher + Notice echoReportsPerInput = 1 rejectOutputsPerAcceptedInput = 2 // Voucher + Notice (no DelegateCallVoucher) - rejectReportsPerAcceptedInput = 1 + rejectReportsPerInput = 1 ) // echoLifecycleConfig configures a shared echo lifecycle test that covers: @@ -145,6 +146,16 @@ func runEchoLifecycleTest(ctx context.Context, t testing.TB, require *require.As require.NoError(err, "read reports") require.Equal(uint64(echoReportsPerInput), reportsResp.Pagination.TotalCount, "expected %d report(s)", echoReportsPerInput) + require.Len(reportsResp.Data, echoReportsPerInput) + input, err := readInput(ctx, cfg.AppName, inputIndex) + require.NoError(err, "read report's input") + for i, report := range reportsResp.Data { + require.Equal(uint64(i), report.Index) + require.Equal(inputIndex, report.InputIndex) + require.Equal(input.EpochIndex, report.EpochIndex) + require.Equal([]byte(cfg.Payload), report.RawData) + } + verifyReportReads(ctx, require, cfg.AppName, reportsResp, reportsResp.Data[0]) t.Log(" all outputs and reports verified") // --- Optional pre-claim hook (e.g. PRT tournament settlement) --- @@ -306,13 +317,26 @@ func runRejectExceptionLifecycleTest( t.Logf(" %d outputs found, none from the failed input — correct", numAccepted*rejectOutputsPerAcceptedInput) - t.Log("Checking reports — same rule: only accepted inputs produce reports...") + t.Log("Checking reports — every completed input keeps its reports...") reportsResp, err := readReports(ctx, cfg.AppName) require.NoError(err, "read reports") - require.Equal(numAccepted*rejectReportsPerAcceptedInput, reportsResp.Pagination.TotalCount, - "expected %d reports (%d per accepted input x %d accepted inputs)", - numAccepted*rejectReportsPerAcceptedInput, rejectReportsPerAcceptedInput, numAccepted) - t.Logf(" %d reports found — correct", numAccepted*rejectReportsPerAcceptedInput) + processed := uint64(numInputs) + if terminal { + processed = 2 + } + require.Equal(processed*rejectReportsPerInput, reportsResp.Pagination.TotalCount) + require.Len(reportsResp.Data, int(processed*rejectReportsPerInput)) + for i, report := range reportsResp.Data { + inputIndex := uint64(i) / rejectReportsPerInput + require.Equal(uint64(i), report.Index) + require.Equal(inputIndex, report.InputIndex) + require.Equal([]byte(fmt.Sprintf("%s-payload-%d", cfg.TestName, inputIndex)), report.RawData) + input, err := readInput(ctx, cfg.AppName, report.InputIndex) + require.NoError(err, "read report's input") + require.Equal(input.EpochIndex, report.EpochIndex) + } + verifyReportReads(ctx, require, cfg.AppName, reportsResp, reportsResp.Data[rejectReportsPerInput]) + t.Logf(" %d reports found — correct", reportsResp.Pagination.TotalCount) if terminal { t.Logf("=== %s test complete: %s terminalized execution before the later input ===", @@ -373,6 +397,62 @@ func runRejectExceptionLifecycleTest( t.Logf("=== %s test complete: %s handling + L1 execution verified ===", cfg.TestName, cfg.FailStatus) } +// verifyReportReads checks list filters and lookup through the database and API. +// The fixture's complete report set fits in the supplied page. +func verifyReportReads( + ctx context.Context, + require *require.Assertions, + appName string, + all *api.ListResponse[model.Report], + report model.Report, +) { + epochReports := make([]model.Report, 0, len(all.Data)) + inputReports := make([]model.Report, 0, len(all.Data)) + for _, entry := range all.Data { + if entry.EpochIndex == report.EpochIndex { + epochReports = append(epochReports, entry) + if entry.InputIndex == report.InputIndex { + inputReports = append(inputReports, entry) + } + } + } + apiArgs := []string{ + "--jsonrpc=true", "--jsonrpc-api-url", + envOrDefault("CARTESI_JSONRPC_API_URL", "http://localhost:10011/rpc"), + } + apiReports, err := readReports(ctx, appName, apiArgs...) + require.NoError(err, "list reports through JSON-RPC") + require.Equal(all.Pagination, apiReports.Pagination) + requireReportsEqual(require, all.Data, apiReports.Data) + for _, transportArgs := range [][]string{nil, apiArgs} { + epochArgs := append([]string{"--epoch-index", strconv.FormatUint(report.EpochIndex, 10)}, transportArgs...) + filtered, err := readReports(ctx, appName, epochArgs...) + require.NoError(err, "list reports by epoch") + require.Equal(uint64(len(epochReports)), filtered.Pagination.TotalCount) + requireReportsEqual(require, epochReports, filtered.Data) + epochArgs = append(epochArgs, "--input-index", strconv.FormatUint(report.InputIndex, 10)) + filtered, err = readReports(ctx, appName, epochArgs...) + require.NoError(err, "list reports by epoch and input") + require.Equal(uint64(len(inputReports)), filtered.Pagination.TotalCount) + requireReportsEqual(require, inputReports, filtered.Data) + single, err := readReport(ctx, appName, report.Index, transportArgs...) + require.NoError(err, "get report by index") + require.NotNil(single) + requireReportsEqual(require, []model.Report{report}, []model.Report{*single}) + } +} + +func requireReportsEqual(require *require.Assertions, expected, actual []model.Report) { + require.Len(actual, len(expected)) + for i, want := range expected { + got := actual[i] + // The CLI and node can encode the same database instant in different zones. + want.CreatedAt, want.UpdatedAt = want.CreatedAt.UTC(), want.UpdatedAt.UTC() + got.CreatedAt, got.UpdatedAt = got.CreatedAt.UTC(), got.UpdatedAt.UTC() + require.Equal(want, got) + } +} + func minePastEpochBoundary( ctx context.Context, t testing.TB, diff --git a/test/integration/terminal_machine_states_test.go b/test/integration/terminal_machine_states_test.go index 64cc8c658..071af66fc 100644 --- a/test/integration/terminal_machine_states_test.go +++ b/test/integration/terminal_machine_states_test.go @@ -185,7 +185,7 @@ func (s *TerminalMachineStatesSuite) runTerminalMachineState(tc terminalMachineS require.Zero(outputs.Pagination.TotalCount, "terminal fixture must not emit outputs") reports, err := readReports(s.ctx, s.appName) require.NoError(err, "read reports") - require.Zero(reports.Pagination.TotalCount, "terminal fixture must not emit reports") + require.Zero(reports.Pagination.TotalCount, "fixture emits no reports") s.T().Logf("Restarting the node after durable %s...", tc.inputStatus) stopSharedNode(s.T())