Skip to content
Open
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
29 changes: 29 additions & 0 deletions internal/datastore/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,32 @@ type RevisionUnavailableError struct {
func NewRevisionUnavailableError(err error) error {
return RevisionUnavailableError{err}
}

// SchemaNotInitializedError is returned when a datastore operation fails because the
// required database tables do not exist. This typically means that migrations have not been run.
type SchemaNotInitializedError struct {
error
}

func (err SchemaNotInitializedError) GRPCStatus() *status.Status {
return spiceerrors.WithCodeAndDetails(
err,
codes.FailedPrecondition,
spiceerrors.ForReason(
v1.ErrorReason_ERROR_REASON_DATASTORE_NOT_MIGRATED,
map[string]string{},
),
)
}

func (err SchemaNotInitializedError) Unwrap() error {
return err.error
}

// NewSchemaNotInitializedError creates a new SchemaNotInitializedError with a helpful message
// instructing the user to run migrations.
func NewSchemaNotInitializedError(underlying error) error {
return SchemaNotInitializedError{
fmt.Errorf("%w. please run \"spicedb datastore migrate\"", underlying),
}
}
55 changes: 55 additions & 0 deletions internal/datastore/common/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package common

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
)

func TestSchemaNotInitializedError(t *testing.T) {
underlyingErr := fmt.Errorf("relation \"caveat\" does not exist (SQLSTATE 42P01)")
err := NewSchemaNotInitializedError(underlyingErr)

t.Run("error message contains migration instructions", func(t *testing.T) {
require.Contains(t, err.Error(), "spicedb datastore migrate")
// The error message now includes the underlying error first, followed by the instruction
require.Contains(t, err.Error(), "relation \"caveat\" does not exist")
})

t.Run("unwrap returns underlying error", func(t *testing.T) {
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, err, &schemaErr)
require.ErrorIs(t, schemaErr.Unwrap(), underlyingErr)
})

t.Run("grpc status is FailedPrecondition", func(t *testing.T) {
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, err, &schemaErr)
status := schemaErr.GRPCStatus()
require.Equal(t, codes.FailedPrecondition, status.Code())
})

t.Run("can be detected with errors.As", func(t *testing.T) {
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, err, &schemaErr)
})

t.Run("wrapped error preserves chain", func(t *testing.T) {
wrappedErr := fmt.Errorf("outer: %w", err)
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, wrappedErr, &schemaErr)
})

t.Run("grpc status extractable from wrapped error", func(t *testing.T) {
// This tests the scenario where SchemaNotInitializedError is wrapped
// by another fmt.Errorf (e.g., in crdb/caveat.go). The gRPC library
// uses errors.As to extract GRPCStatus from wrapped errors.
wrappedErr := fmt.Errorf("outer context: %w", err)
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, wrappedErr, &schemaErr)
status := schemaErr.GRPCStatus()
require.Equal(t, codes.FailedPrecondition, status.Code())
})
}
8 changes: 8 additions & 0 deletions internal/datastore/crdb/caveat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
sq "github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v5"

"github.com/authzed/spicedb/internal/datastore/common"
"github.com/authzed/spicedb/internal/datastore/crdb/schema"
pgxcommon "github.com/authzed/spicedb/internal/datastore/postgres/common"
"github.com/authzed/spicedb/internal/datastore/revisions"
"github.com/authzed/spicedb/pkg/datastore"
core "github.com/authzed/spicedb/pkg/proto/core/v1"
Expand Down Expand Up @@ -53,6 +55,9 @@ func (cr *crdbReader) LegacyReadCaveatByName(ctx context.Context, name string) (
if errors.Is(err, pgx.ErrNoRows) {
err = datastore.NewCaveatNameNotFoundErr(name)
}
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, datastore.NoRevision, fmt.Errorf(errReadCaveat, name, err)
}

Expand Down Expand Up @@ -109,6 +114,9 @@ func (cr *crdbReader) lookupCaveats(ctx context.Context, caveatNames []string) (
return nil
}, sql, args...)
if err != nil {
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, fmt.Errorf(errListCaveats, err)
}

Expand Down
3 changes: 3 additions & 0 deletions internal/datastore/crdb/crdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,9 @@ func (cds *crdbDatastore) features(ctx context.Context) (*datastore.Features, er

head, err := cds.HeadRevision(ctx)
if err != nil {
if pgxcommon.IsMissingTableError(err) {
return nil, common.NewSchemaNotInitializedError(err)
}
return nil, err
}

Expand Down
4 changes: 1 addition & 3 deletions internal/datastore/crdb/migrations/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import (
const (
errUnableToInstantiate = "unable to instantiate CRDBDriver: %w"

postgresMissingTableErrorCode = "42P01"

queryLoadVersion = "SELECT version_num from schema_version"
queryWriteVersion = "UPDATE schema_version SET version_num=$1 WHERE version_num=$2"
)
Expand Down Expand Up @@ -53,7 +51,7 @@ func (apd *CRDBDriver) Version(ctx context.Context) (string, error) {

if err := apd.db.QueryRow(ctx, queryLoadVersion).Scan(&loaded); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == postgresMissingTableErrorCode {
if errors.As(err, &pgErr) && pgErr.Code == pgxcommon.PgMissingTable {
return "", nil
}
return "", fmt.Errorf("unable to load alembic revision: %w", err)
Expand Down
6 changes: 6 additions & 0 deletions internal/datastore/crdb/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ func (cr crdbReader) lookupNamespaces(ctx context.Context, tx pgxcommon.DBFuncQu
return nil
}, sql, args...)
if err != nil {
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, err
}

Expand Down Expand Up @@ -418,6 +421,9 @@ func loadAllNamespaces(ctx context.Context, tx pgxcommon.DBFuncQuerier, fromBuil
return nil
}, sql, args...)
if err != nil {
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, sql, err
}

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

package crdb

import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/authzed/spicedb/internal/datastore/common"
"github.com/authzed/spicedb/internal/datastore/revisions"
testdatastore "github.com/authzed/spicedb/internal/testserver/datastore"
)

func TestSchemaNotInitializedErrors(t *testing.T) {
b := testdatastore.RunCRDBForTesting(t, crdbTestVersion())
uri := b.NewDatabase(t)

ds, err := NewCRDBDatastore(t.Context(), uri, WithAcquireTimeout(5*time.Second))
require.NoError(t, err)
t.Cleanup(func() { _ = ds.Close() })

requireSchemaNotInitialized := func(t *testing.T, err error) {
t.Helper()
require.ErrorAs(t, err, &common.SchemaNotInitializedError{})
require.ErrorContains(t, err, "spicedb datastore migrate")
}

t.Run("Features", func(t *testing.T) {
_, err := ds.Features(t.Context())
requireSchemaNotInitialized(t, err)
})

reader := ds.SnapshotReader(revisions.NewHLCForTime(time.Now()))

t.Run("LegacyReadCaveatByName", func(t *testing.T) {
_, _, err := reader.LegacyReadCaveatByName(t.Context(), "somecaveat")
requireSchemaNotInitialized(t, err)
})

t.Run("LegacyListAllCaveats", func(t *testing.T) {
_, err := reader.LegacyListAllCaveats(t.Context())
requireSchemaNotInitialized(t, err)
})

t.Run("LegacyListAllNamespaces", func(t *testing.T) {
_, err := reader.LegacyListAllNamespaces(t.Context())
requireSchemaNotInitialized(t, err)
})

t.Run("LegacyLookupNamespacesWithNames", func(t *testing.T) {
_, err := reader.LegacyLookupNamespacesWithNames(t.Context(), []string{"document"})
requireSchemaNotInitialized(t, err)
})
}
8 changes: 8 additions & 0 deletions internal/datastore/postgres/caveat.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
sq "github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v5"

"github.com/authzed/spicedb/internal/datastore/common"
pgxcommon "github.com/authzed/spicedb/internal/datastore/postgres/common"
"github.com/authzed/spicedb/internal/datastore/postgres/schema"
"github.com/authzed/spicedb/pkg/datastore"
"github.com/authzed/spicedb/pkg/genutil/mapz"
Expand Down Expand Up @@ -49,6 +51,9 @@ func (r *pgReader) LegacyReadCaveatByName(ctx context.Context, name string) (*co
if errors.Is(err, pgx.ErrNoRows) {
return nil, datastore.NoRevision, datastore.NewCaveatNameNotFoundErr(name)
}
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, datastore.NoRevision, fmt.Errorf(errReadCaveat, err)
}
def := core.CaveatDefinition{}
Expand Down Expand Up @@ -106,6 +111,9 @@ func (r *pgReader) lookupCaveats(ctx context.Context, caveatNames []string) ([]d
return rows.Err()
}, sql, args...)
if err != nil {
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, fmt.Errorf(errListCaveats, err)
}

Expand Down
16 changes: 11 additions & 5 deletions internal/datastore/postgres/common/bulk.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/ccoveille/go-safecast/v2"
"github.com/jackc/pgx/v5"

dscommon "github.com/authzed/spicedb/internal/datastore/common"
"github.com/authzed/spicedb/pkg/datastore"
"github.com/authzed/spicedb/pkg/spiceerrors"
"github.com/authzed/spicedb/pkg/tuple"
Expand Down Expand Up @@ -77,11 +78,16 @@ func BulkLoad(
colNames: colNames,
}
copied, err := tx.CopyFrom(ctx, pgx.Identifier{tupleTableName}, colNames, adapter)
if err != nil && adapter.err != nil {
// When the relationship source aborts the copy, the driver reports the failure as
// an opaque COPY error that hides the real cause and exposes datastore internals.
// Prefer the source's own error, which is meaningful and free of those internals.
err = adapter.err
if err != nil {
switch {
case adapter.err != nil:
// When the relationship source aborts the copy, the driver reports the failure as
// an opaque COPY error that hides the real cause and exposes datastore internals.
// Prefer the source's own error, which is meaningful and free of those internals.
err = adapter.err
case IsMissingTableError(err):
err = dscommon.NewSchemaNotInitializedError(err)
}
}
uintCopied, castErr := safecast.Convert[uint64](copied)
if castErr != nil {
Expand Down
11 changes: 11 additions & 0 deletions internal/datastore/postgres/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const (
pgReadOnlyTransaction = "25006"
pgQueryCanceled = "57014"
pgInvalidArgument = "22023"

// PgMissingTable is the Postgres error code for "relation does not exist".
// This is used to detect when migrations have not been run.
PgMissingTable = "42P01"
)

var (
Expand Down Expand Up @@ -106,3 +110,10 @@ func ConvertToWriteConstraintError(livingTupleConstraints []string, err error) e

return nil
}

// IsMissingTableError returns true if the error is a Postgres error indicating a missing table.
// This typically happens when migrations have not been run.
func IsMissingTableError(err error) bool {
var pgerr *pgconn.PgError
return errors.As(err, &pgerr) && pgerr.Code == PgMissingTable
}
45 changes: 45 additions & 0 deletions internal/datastore/postgres/common/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package common

import (
"fmt"
"testing"

"github.com/jackc/pgx/v5/pgconn"
"github.com/stretchr/testify/require"
)

func TestIsMissingTableError(t *testing.T) {
t.Run("returns true for missing table error", func(t *testing.T) {
pgErr := &pgconn.PgError{
Code: PgMissingTable,
Message: "relation \"caveat\" does not exist",
}
require.True(t, IsMissingTableError(pgErr))
})

t.Run("returns false for other postgres errors", func(t *testing.T) {
pgErr := &pgconn.PgError{
Code: pgSerializationFailure,
Message: "could not serialize access",
}
require.False(t, IsMissingTableError(pgErr))
})

t.Run("returns false for non-postgres errors", func(t *testing.T) {
err := fmt.Errorf("some other error")
require.False(t, IsMissingTableError(err))
})

t.Run("returns false for nil error", func(t *testing.T) {
require.False(t, IsMissingTableError(nil))
})

t.Run("returns true for wrapped missing table error", func(t *testing.T) {
pgErr := &pgconn.PgError{
Code: PgMissingTable,
Message: "relation \"caveat\" does not exist",
}
wrappedErr := fmt.Errorf("query failed: %w", pgErr)
require.True(t, IsMissingTableError(wrappedErr))
})
}
4 changes: 1 addition & 3 deletions internal/datastore/postgres/migrations/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import (
"github.com/authzed/spicedb/pkg/migrate"
)

const postgresMissingTableErrorCode = "42P01"

var tracer = otel.Tracer("spicedb/internal/datastore/common")

// AlembicPostgresDriver implements a schema migration facility for use in
Expand Down Expand Up @@ -74,7 +72,7 @@ func (apd *AlembicPostgresDriver) Version(ctx context.Context) (string, error) {

if err := apd.db.QueryRow(ctx, "SELECT version_num from alembic_version").Scan(&loaded); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == postgresMissingTableErrorCode {
if errors.As(err, &pgErr) && pgErr.Code == pgxcommon.PgMissingTable {
return "", nil
}
return "", fmt.Errorf("unable to load alembic revision: %w", err)
Expand Down
3 changes: 3 additions & 0 deletions internal/datastore/postgres/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ func loadAllNamespaces(
return rows.Err()
}, sql, args...)
if err != nil {
if pgxcommon.IsMissingTableError(err) {
err = common.NewSchemaNotInitializedError(err)
}
return nil, err
}

Expand Down
6 changes: 6 additions & 0 deletions internal/datastore/postgres/revisions.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ func (pgd *pgDatastore) getHeadRevisionWithHash(ctx context.Context, querier com
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil
}
if common.IsMissingTableError(err) {
err = dscommon.NewSchemaNotInitializedError(err)
}

return nil, nil, fmt.Errorf(errRevision, err)
}
Expand Down Expand Up @@ -341,6 +344,9 @@ func createNewTransaction(ctx context.Context, tx pgx.Tx, metadata map[string]an

cterr := tx.QueryRow(ctx, sql, args...).Scan(&newXID, &newSnapshot, &timestamp)
if cterr != nil {
if common.IsMissingTableError(cterr) {
cterr = dscommon.NewSchemaNotInitializedError(cterr)
}
err = fmt.Errorf("error when trying to create a new transaction: %w", cterr)
}
return newXID, newSnapshot, timestamp, err
Expand Down
Loading
Loading