Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Group lines under `Added` / `Changed` / `Fixed` / `Removed`. Append a PR link
### Fixed

- Picodata TPC-C reserves order IDs atomically to prevent concurrent New-Order duplicates, and explicitly identifies execution as a non-transactional approximation requiring Picodata 26.2 or newer. ([#251](https://github.com/stroppy-io/stroppy/pull/251))
- Unique-key and other constraint failures have distinct error classes, and terminal-error groups retain a bounded, sanitized sample and backend code without changing iteration continuation. ([#252](https://github.com/stroppy-io/stroppy/pull/252))

## [6.3.1] - 2026-11-02

Expand Down
17 changes: 17 additions & 0 deletions docs/workload-authoring-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,23 @@ blindly retrying uncertain outcomes. Backoff honors cancellation. Scheduled
retries have separate metrics and do not count failed iterations if they succeed.
`Retry0` and `TxRetryPolicy` also support procedure/grouped operations.

`unique_violation` identifies duplicate primary/unique keys; `constraint_violation`
identifies foreign-key, not-null, and check failures. Neither retries by default
or implies a broken workload: reused data or conflicting clients can also cause
them. A terminal error fails the iteration while the VU continues under the
existing continuation policy.

Terminal-error groups retain their first representative `sample` and native
backend `code` in saved reports and the text summary. Samples are capped at 512
UTF-8 bytes, remove configured credentials, quoted values, recognized SQL and
argument suffixes, and omit PostgreSQL detail/context fields. SQL recognition
covers SELECT, INSERT/REPLACE INTO, UPDATE ... SET, DELETE FROM, CALL,
CREATE/ALTER/DROP TABLE, and WITH ... AS. If credential redaction overlaps a
recognized SQL-bearing message, the entire sample is omitted. This is not a SQL
parser or a general data-loss-prevention boundary. Samples are diagnostic, not
metric labels; group cardinality remains bounded. Arbitrary unquoted application
error text can still contain authored data: do not put secrets in errors.

Managed transactions count one logical success across retries. Use
`LogicalOperation` for a procedure or custom grouped operation; nested managed
transactions do not double-count. A logical success is not a TPC compliance score.
Expand Down
36 changes: 28 additions & 8 deletions pkg/bench/error_reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ type errorGroup struct {

type errorGroupSummary struct {
errorGroup
count uint64
count uint64
code string
sample string
}

type errorSummary struct {
Expand All @@ -50,7 +52,9 @@ type errorSummary struct {
}

type errorGroupState struct {
count atomic.Uint64
count atomic.Uint64
code string
sample string
}

type errorReporter struct {
Expand Down Expand Up @@ -132,7 +136,13 @@ func (r *errorReporter) record(
operation: boundErrorOperation(operation),
kind: normalizeErrorKind(kind),
}
group, state := r.groupState(group)

var redactor *strings.Replacer
if vu != nil && vu.root != nil {
redactor = vu.root.reportRedactor
}

group, state := r.groupState(group, err, redactor)
count := state.count.Add(1)

switch scope {
Expand Down Expand Up @@ -166,7 +176,9 @@ func (r *errorReporter) record(
}
}

func (r *errorReporter) groupState(group errorGroup) (errorGroup, *errorGroupState) {
func (r *errorReporter) groupState(
group errorGroup, err error, redactor *strings.Replacer,
) (errorGroup, *errorGroupState) {
r.mu.RLock()
state := r.groups[group]
r.mu.RUnlock()
Expand All @@ -175,6 +187,8 @@ func (r *errorReporter) groupState(group errorGroup) (errorGroup, *errorGroupSta
return group, state
}

code, sample := representativeError(err, redactor)

r.mu.Lock()
defer r.mu.Unlock()

Expand All @@ -192,7 +206,7 @@ func (r *errorReporter) groupState(group errorGroup) (errorGroup, *errorGroupSta
group.operation = strings.Clone(group.operation)
}

state = &errorGroupState{}
state = &errorGroupState{code: code, sample: sample}
r.groups[group] = state

return group, state
Expand Down Expand Up @@ -261,7 +275,9 @@ func (r *errorReporter) snapshot() errorSummary {
summary.groups = make([]errorGroupSummary, 0, len(r.groups))

for group, state := range r.groups {
summary.groups = append(summary.groups, errorGroupSummary{errorGroup: group, count: state.count.Load()})
summary.groups = append(summary.groups, errorGroupSummary{
errorGroup: group, count: state.count.Load(), code: state.code, sample: state.sample,
})
}

r.mu.RUnlock()
Expand Down Expand Up @@ -313,10 +329,12 @@ func (r *errorReporter) writeSummary(w io.Writer) {
for _, group := range summary.groups {
fmt.Fprintf(
w,
" operation=%-20s class=%-14s count=%d\n",
" operation=%-20s class=%-14s count=%d code=%s sample=%q\n",
group.operation,
group.kind,
group.count,
group.code,
group.sample,
)
}
}
Expand Down Expand Up @@ -351,7 +369,9 @@ func boundErrorOperation(operation string) string {

func normalizeErrorKind(kind driver.ErrorKind) driver.ErrorKind {
switch kind {
case driver.ErrorKindUnknown,
case driver.ErrorKindUniqueViolation,
driver.ErrorKindConstraintViolation,
driver.ErrorKindUnknown,
driver.ErrorKindSerialization,
driver.ErrorKindDeadlock,
driver.ErrorKindLockTimeout,
Expand Down
4 changes: 2 additions & 2 deletions pkg/bench/error_reporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,6 @@ func TestErrorSummaryIsProminentAndDeterministic(t *testing.T) {
strings.Index(text, "operation=iteration"),
strings.Index(text, "operation=q2"),
)
require.NotContains(t, text, "first")
require.NotContains(t, text, "second")
require.Contains(t, text, `sample="first"`)
require.Contains(t, text, `sample="second"`)
}
86 changes: 86 additions & 0 deletions pkg/bench/error_sample.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package bench

import (
"errors"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"

"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
)

const maxErrorSampleBytes = 512

var (
errorSampleQuoted = regexp.MustCompile("\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'|`[^`]*`")
errorSampleSQL = regexp.MustCompile(`(?is)\b(SELECT\s+|(?:INSERT|REPLACE)\s+INTO\s+|UPDATE\s+\S+\s+SET\s+|` +
`DELETE\s+FROM\s+|CALL\s+|CREATE\s+TABLE\s+|ALTER\s+TABLE\s+|DROP\s+TABLE\s+|WITH\s+\S+\s+AS\s*\().*`)
errorSampleArguments = regexp.MustCompile(`(?is)\b(args|arguments|parameters)\s*[:=\[]?.*`)
)

func backendErrorSample(err error) (code, sample string) {
if err == nil {
return "", ""
}

if backend, ok := errors.AsType[*pgconn.PgError](err); ok {
return backend.Code, backend.Message
}

if backend, ok := errors.AsType[*mysql.MySQLError](err); ok {
return strconv.FormatUint(uint64(backend.Number), 10), backend.Message
}

for {
if cause := errors.Unwrap(err); cause != nil {
err = cause

continue
}

if joined, ok := err.(interface{ Unwrap() []error }); ok && len(joined.Unwrap()) > 0 {
err = joined.Unwrap()[0]

continue
}

return "", err.Error()
}
}

func representativeError(err error, redactor *strings.Replacer) (code, sample string) {
code, sample = backendErrorSample(err)

if redactor != nil {
redacted := redactor.Replace(sample)
if redacted != sample && errorSampleSQL.MatchString(sample) {
redacted = "<SQL omitted>"
}

sample = redacted
}

sample = errorSampleQuoted.ReplaceAllString(sample, "<redacted>")
sample = errorSampleArguments.ReplaceAllString(sample, "<arguments omitted>")
sample = errorSampleSQL.ReplaceAllString(sample, "<SQL omitted>")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
sample = strings.ToValidUTF8(sample, "?")

sample = strings.Map(func(r rune) rune {
if unicode.IsControl(r) || unicode.In(r, unicode.Cf) {
return ' '
}

return r
}, sample)
if len(sample) > maxErrorSampleBytes {
sample = sample[:maxErrorSampleBytes]
for !utf8.ValidString(sample) {
sample = sample[:len(sample)-1]
}
}

return code, strings.TrimSpace(sample)
}
154 changes: 154 additions & 0 deletions pkg/bench/error_sample_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package bench

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"

"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/stroppy-io/stroppy/v6/pkg/driver"
)

func TestRepresentativeError(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
err error
code string
want string
}{
{"postgres", fmt.Errorf("SELECT secret args [secret]: %w", &pgconn.PgError{
Code: "23505", Message: `duplicate key value violates unique constraint "secret_index"`,
Detail: "Key (password)=(secret) already exists", Where: "SELECT secret",
}), "23505", "duplicate key value violates unique constraint <redacted>"},
{
"mysql", &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'secret' for key 'PRIMARY'"},
"1062", "Duplicate entry <redacted> for key <redacted>",
},
{
"wrapped unknown", fmt.Errorf("query SELECT secret: %w", errors.New("backend unavailable")),
"", "backend unavailable",
},
{"arguments", errors.New("backend failure; args [secret]"), "", "backend failure; <arguments omitted>"},
{"sql", errors.New("backend failure: SELECT secret FROM table"), "", "<SQL omitted>"},
{
"replace", errors.New("backend failure: REPLACE INTO users VALUES (12345)"),
"", "backend failure: <SQL omitted>",
},
{"configured secret", errors.New("authentication failed secret"), "", "authentication failed <redacted>"},
{
"serialization", &pgconn.PgError{Code: "40001", Message: "could not serialize access due to concurrent update"},
"40001", "could not serialize access due to concurrent update",
},
{
"foreign key", &mysql.MySQLError{
Number: 1452,
Message: "Cannot add or update a child row: a foreign key constraint fails ('secret')",
},
"1452", "Cannot add or update a child row: a foreign key constraint fails (<redacted>)",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

code, sample := representativeError(tc.err, strings.NewReplacer("secret", "<redacted>"))
require.Equal(t, tc.code, code)
require.Equal(t, tc.want, sample)
require.NotContains(t, sample, "secret")
})
}
}

func TestErrorSampleRedactionCannotDisableSQLOmission(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
secret string
message string
}{
{"SET", "execution failed: UPDATE users SET password=12345"},
{"UPDATE", "execution failed: UPDATE users SET password=12345"},
{"'prefix' SELECT", "failure 'prefix' SELECT private_suffix FROM users"},
{"prefix'quote", "failure prefix'quote private_suffix: REPLACE INTO users VALUES (12345)"},
} {
t.Run(tc.secret, func(t *testing.T) {
t.Parallel()

_, sample := representativeError(errors.New(tc.message), strings.NewReplacer(tc.secret, "<redacted>"))
require.Equal(t, "<SQL omitted>", sample)
})
}
}

func TestErrorReporterRetainsBoundedFirstSample(t *testing.T) {
t.Parallel()

reporter := newErrorReporter(zap.NewNop(), time.Hour)
t.Cleanup(reporter.stopAndWait)

first := "first " + strings.Repeat(string(rune(0x754c)), maxErrorSampleBytes)
reporter.record(nil, terminalErrorIteration, "workload", &pgconn.PgError{Code: "XX000", Message: first}, nil)

var workers sync.WaitGroup
for range 16 {
workers.Go(func() {
reporter.record(nil, terminalErrorIteration, "workload", errors.New("later error"), nil)
})
}

workers.Wait()

summary := reporter.snapshot()
require.Len(t, summary.groups, 1)
require.Equal(t, uint64(17), summary.groups[0].count)
require.True(t, strings.HasPrefix(summary.groups[0].sample, "first "))
require.LessOrEqual(t, len(summary.groups[0].sample), maxErrorSampleBytes)
require.True(t, utf8.ValidString(summary.groups[0].sample))
converted := reportErrors(summary)
require.Equal(t, summary.groups[0].sample, converted.Groups[0].Sample)
require.Equal(t, "XX000", converted.Groups[0].Code)
data, err := json.Marshal(converted)
require.NoError(t, err)
require.Contains(t, string(data), `"code":"XX000"`)

var output bytes.Buffer
reporter.writeSummary(&output)
require.Contains(t, output.String(), "sample=\"first ")
}

func TestConstraintErrorsRemainTerminalAndContinue(t *testing.T) {
t.Parallel()

reporter := newErrorReporter(zap.NewNop(), time.Hour)
t.Cleanup(reporter.stopAndWait)

for _, kind := range []driver.ErrorKind{driver.ErrorKindUniqueViolation, driver.ErrorKindConstraintViolation} {
policy := newTxRetryPolicy(func(error) driver.ErrorFacts { return driver.ErrorFacts{Kind: kind} },
TxRetryPolicyOptions{MaxAttempts: 3})
attempts := 0
err := Retry0(context.Background(), policy, func() error {
attempts++

return errors.New("constraint rejected")
})
require.Error(t, err)
require.False(t, IsFatalError(err))
require.Equal(t, 1, attempts)
reporter.record(nil, terminalErrorIteration, "workload", err, policy.Classify)
}

require.Equal(t, uint64(2), reporter.snapshot().failedIterations)
require.Len(t, reporter.snapshot().groups, 2)
}
1 change: 1 addition & 0 deletions pkg/bench/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ func reportErrors(summary errorSummary) report.ErrorSummary {
for _, group := range summary.groups {
out.Groups = append(out.Groups, report.ErrorGroup{
Operation: group.operation, Class: string(group.kind), Count: group.count,
Code: group.code, Sample: group.sample,
})
}

Expand Down
Loading
Loading