Skip to content
Draft
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 @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Query Planner: fast serialize/deserialize for query plans (https://github.com/authzed/spicedb/pull/3122)
- CockroachDB: new experimental flag `--datastore-experimental-crdb-query-cancellation` that cancels in-flight queries via CockroachDB's `CANCEL QUERIES` statement when their context is canceled, keeping connections alive instead of destroying them and preventing the pool-drain death spiral under load. Recommended pairing: `--write-conn-acquisition-timeout=0`.

### Changed
- Cache: switch to [otter](https://maypok86.github.io/otter/) as the primary cache implementation (https://github.com/authzed/spicedb/pull/3112)
Expand Down
3 changes: 3 additions & 0 deletions docs/spicedb.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions internal/datastore/crdb/cancellation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//go:build datastore

package crdb

import (
"context"
"math/rand/v2"
"testing"
"time"

"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"

"github.com/authzed/spicedb/internal/datastore/crdb/pool"
testdatastore "github.com/authzed/spicedb/internal/testserver/datastore"
"github.com/authzed/spicedb/pkg/datastore"
)

// newCancellationTestDatastore builds an unwrapped *crdbDatastore with
// in-band query cancellation enabled, against a dockerized CRDB.
func newCancellationTestDatastore(t *testing.T) *crdbDatastore {
t.Helper()
b := testdatastore.RunCRDBForTesting(t, "", crdbTestVersion())

var cds *crdbDatastore
b.NewDatastore(t, func(engine, uri string) datastore.Datastore {
ds, err := newCRDBDatastore(t.Context(), uri,
GCWindow(veryLargeGCWindow),
RevisionQuantization(0),
WithQueryCancellation(true),
WithAcquireTimeout(5*time.Second),
)
require.NoError(t, err)
t.Cleanup(func() { _ = ds.Close() })
cds = ds.(*crdbDatastore)
return ds
})
require.NotNil(t, cds)
return cds
}

func scanIgnore(_ context.Context, row pgx.Row) error {
var ignored any
return row.Scan(&ignored)
}

func TestQueryCancellationSessionIDsRegistered(t *testing.T) {
cds := newCancellationTestDatastore(t)
ctx := t.Context()

// Force at least one write connection to exist.
require.NoError(t, cds.writePool.QueryRowFunc(ctx, scanIgnore, "SELECT 1"))

found := 0
cds.writePool.Range(func(conn *pgx.Conn, _ uint32) {
if _, ok := cds.canceler.SessionID(conn.PgConn()); ok {
found++
}
})
require.Positive(t, found, "write pool connections must have registered session IDs")
}

func TestQueryCancellationRollsBackWriteAndKeepsConnection(t *testing.T) {
cds := newCancellationTestDatastore(t)
ctx := t.Context()

require.NoError(t, cds.writePool.ExecFunc(ctx,
func(_ context.Context, _ pgconn.CommandTag, err error) error { return err },
"CREATE TABLE IF NOT EXISTS cancellation_test (id INT PRIMARY KEY)"))

baselineNewConns := cds.writePool.Stat().NewConnsCount()

cancelCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
err := cds.writePool.BeginTxFunc(cancelCtx, pgx.TxOptions{}, func(tx pgx.Tx) error {
if _, err := tx.Exec(cancelCtx, "INSERT INTO cancellation_test (id) VALUES (1)"); err != nil {
return err
}
_, err := tx.Exec(cancelCtx, "SELECT pg_sleep(10)")
return err
})
require.ErrorIs(t, err, context.DeadlineExceeded)

// The transaction must have been rolled back on a live connection.
var count int
require.NoError(t, cds.writePool.QueryRowFunc(ctx, func(_ context.Context, row pgx.Row) error {
return row.Scan(&count)
}, "SELECT COUNT(*) FROM cancellation_test"))
require.Zero(t, count, "canceled transaction must be rolled back")
require.Equal(t, baselineNewConns, cds.writePool.Stat().NewConnsCount(),
"write cancellation must not destroy and replace the connection")
}

// TestQueryCancellationStressNoStaleErrors races cancellation against query
// completion many times and verifies that no cancellation ever bleeds into a
// subsequent query on the same pool — the failure mode that forced the revert
// of pgwire-protocol cancellation in #2434.
func TestQueryCancellationStressNoStaleErrors(t *testing.T) {
cds := newCancellationTestDatastore(t)
ctx := t.Context()

tripwireBefore := testutil.ToFloat64(pool.UnexpectedCancellationErrors)

for i := 0; i < 300; i++ {
// Jitter the deadline around the query duration (~10ms) so the
// cancellation races completion in both directions.
timeout := time.Duration(5+rand.IntN(15)) * time.Millisecond //nolint:gosec // jitter, not crypto
cancelCtx, cancel := context.WithTimeout(ctx, timeout)
_ = cds.writePool.QueryRowFunc(cancelCtx, scanIgnore, "SELECT pg_sleep(0.01)")
cancel()

err := cds.writePool.QueryRowFunc(ctx, scanIgnore, "SELECT 1")
require.NoError(t, err,
"iteration %d: follow-up query failed — possible stale cancellation", i)
}

require.InDelta(t, tripwireBefore, testutil.ToFloat64(pool.UnexpectedCancellationErrors), 1e-9,
"no query may observe 57014 without its own context being canceled")
}
21 changes: 21 additions & 0 deletions internal/datastore/crdb/crdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
MigrationValidator: common.NewMigrationValidator(headMigration, config.allowedMigrations),
dburl: url,
acquireTimeout: config.acquireTimeout,
queryCancellationEnabled: config.enableQueryCancellation,
watchBufferLength: config.watchBufferLength,
watchChangeBufferMaximumSize: config.watchChangeBufferMaximumSize,
watchBufferWriteTimeout: config.watchBufferWriteTimeout,
Expand All @@ -203,6 +204,21 @@

// this ctx and cancel is tied to the lifetime of the datastore
ds.ctx, ds.cancel = context.WithCancel(context.Background())

if config.enableQueryCancellation {
cancelPoolConfig, err := pgxpool.ParseConfig(url)
if err != nil {
ds.cancel()
return nil, common.RedactAndLogSensitiveConnString(ctx, errUnableToInstantiate, err, url)
}

Check warning on line 213 in internal/datastore/crdb/crdb.go

View check run for this annotation

Codecov / codecov/patch

internal/datastore/crdb/crdb.go#L211-L213

Added lines #L211 - L213 were not covered by tests
ds.canceler, err = pool.NewCanceler(ds.ctx, cancelPoolConfig)
if err != nil {
ds.cancel()
return nil, common.RedactAndLogSensitiveConnString(ctx, errUnableToInstantiate, err, url)
}

Check warning on line 218 in internal/datastore/crdb/crdb.go

View check run for this annotation

Codecov / codecov/patch

internal/datastore/crdb/crdb.go#L216-L218

Added lines #L216 - L218 were not covered by tests
ds.canceler.InstallOn(writePoolConfig)
}

ds.writePool, err = pool.NewRetryPool(ds.ctx, "write", writePoolConfig, healthChecker, config.maxRetries, config.connectRate)
if err != nil {
ds.cancel()
Expand Down Expand Up @@ -275,6 +291,8 @@
gcWindow time.Duration
schema common.SchemaInformation
acquireTimeout time.Duration
canceler *pool.Canceler
queryCancellationEnabled bool

beginChangefeedQuery string
transactionNowQuery string
Expand Down Expand Up @@ -482,6 +500,9 @@
}
cds.readPool.Close()
cds.writePool.Close()
if cds.canceler != nil {
cds.canceler.Close()
}
for _, collector := range cds.collectors {
ok := prometheus.Unregister(collector)
if !ok {
Expand Down
13 changes: 13 additions & 0 deletions internal/datastore/crdb/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type crdbOptions struct {
includeQueryParametersInTraces bool
watchDisabled bool
acquireTimeout time.Duration
enableQueryCancellation bool
}

const (
Expand Down Expand Up @@ -67,6 +68,7 @@ const (
defaultColumnOptimizationOption = common.ColumnOptimizationOptionStaticValues
defaultIncludeQueryParametersInTraces = false
defaultWatchDisabled = false
defaultEnableQueryCancellation = false
)

// Option provides the facility to configure how clients within the CRDB
Expand Down Expand Up @@ -94,6 +96,7 @@ func generateConfig(options []Option) (crdbOptions, error) {
includeQueryParametersInTraces: defaultIncludeQueryParametersInTraces,
watchDisabled: defaultWatchDisabled,
acquireTimeout: defaultAcquireTimeout,
enableQueryCancellation: defaultEnableQueryCancellation,
}

for _, option := range options {
Expand Down Expand Up @@ -404,3 +407,13 @@ func WithWatchDisabled(isDisabled bool) Option {
func WithAcquireTimeout(timeout time.Duration) Option {
return func(po *crdbOptions) { po.acquireTimeout = timeout }
}

// WithQueryCancellation enables in-band cancellation of in-flight queries via
// CockroachDB's CANCEL QUERIES statement when their context is canceled,
// keeping connections alive instead of destroying them (pgx's default
// behavior). When enabled, reader query contexts are no longer severed.
//
// This is experimental and disabled by default.
func WithQueryCancellation(enabled bool) Option {
return func(po *crdbOptions) { po.enableQueryCancellation = enabled }
}
101 changes: 101 additions & 0 deletions internal/datastore/crdb/pool/cancel_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package pool

import (
"context"
"sync/atomic"
"time"

"github.com/jackc/pgx/v5/pgconn"

log "github.com/authzed/spicedb/internal/logging"
)

// deadlineSetter is the subset of net.Conn the handler needs; it exists so
// the handler can be unit tested without a live connection.
type deadlineSetter interface {
SetDeadline(t time.Time) error
}

// sessionCanceler cancels all queries running on a tracked session. It is
// implemented by *Canceler.
type sessionCanceler interface {
CancelSessionQueries(pgConn *pgconn.PgConn) error
}

// CancelQueryContextWatcherHandler responds to context cancellation during an
// in-flight pgconn operation by issuing CockroachDB's in-band CANCEL QUERIES
// statement from a sibling connection (via the Canceler), so the query stops
// promptly without destroying this connection.
//
// Sequencing guarantee: HandleUnwatchAfterCancel blocks until the CANCEL
// statement round trip completes. pgconn invokes it before the canceled
// operation returns to the caller, and the pool cannot release the connection
// before the operation returns. A cancellation therefore can never still be
// in flight when a later query runs on this session — unlike the pgwire
// cancel protocol, which CockroachDB applies asynchronously after closing the
// cancel connection.
//
// If the CANCEL statement fails or times out, the connection is poisoned with
// an immediate deadline: its next use errors out, the pool destroys it, and
// the pool-level retry logic transparently moves the work to another
// connection. This degrades to the legacy close-the-connection behavior.
type CancelQueryContextWatcherHandler struct {
conn deadlineSetter
pgConn *pgconn.PgConn
canceler sessionCanceler
deadlineDelay time.Duration

cancelFinished chan struct{}
cancelSucceeded atomic.Bool
}

// NewCancelQueryContextWatcherHandler builds a handler for a connection.
// deadlineDelay is the fallback net.Conn deadline applied when a context is
// canceled: if in-band cancellation does not resolve the in-flight operation
// before it elapses, the pending read errors out and the connection is
// destroyed. It must comfortably exceed the canceler's statement timeout.
func NewCancelQueryContextWatcherHandler(pgConn *pgconn.PgConn, canceler sessionCanceler, deadlineDelay time.Duration) *CancelQueryContextWatcherHandler {
return &CancelQueryContextWatcherHandler{
conn: pgConn.Conn(),
pgConn: pgConn,
canceler: canceler,
deadlineDelay: deadlineDelay,
}
}

// HandleCancel implements ctxwatch.Handler.
func (h *CancelQueryContextWatcherHandler) HandleCancel(_ context.Context) {
h.cancelFinished = make(chan struct{})
h.cancelSucceeded.Store(false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea here that there are potentially multiple requests using this same handler? I'm wondering why this isn't in the constructor


// Fallback: if in-band cancellation fails to resolve the in-flight
// operation, this deadline unblocks the pending read so the connection
// errors out and is destroyed.
_ = h.conn.SetDeadline(time.Now().Add(h.deadlineDelay))

go func() {
defer close(h.cancelFinished)
if err := h.canceler.CancelSessionQueries(h.pgConn); err != nil {
log.Warn().Err(err).Msg("in-band query cancellation failed; connection will be discarded")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the risk of Yet Another Metric, successful and failed cancellations seem like they'd be good things to count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nvm I see you beat me to it

return
}
h.cancelSucceeded.Store(true)
}()
}

// HandleUnwatchAfterCancel implements ctxwatch.Handler.
func (h *CancelQueryContextWatcherHandler) HandleUnwatchAfterCancel() {
// Block until the CANCEL statement completes. Releasing the connection
// any earlier would allow a still-in-flight CANCEL to hit whatever query
// runs next on this session.
<-h.cancelFinished

if h.cancelSucceeded.Load() {
_ = h.conn.SetDeadline(time.Time{})
return
}
// The CANCEL statement failed or timed out, so a cancellation may still
// be in flight for this session. Poison the connection so it is
// destroyed instead of reused.
_ = h.conn.SetDeadline(time.Now())
}
Loading
Loading