Skip to content

Commit

Permalink
feat: add database-backed config resolver + provider for production (#…
Browse files Browse the repository at this point in the history
…1570)

Fixes #1548

This doesn't hook anything up yet - just adds the provider and resolver
that we'll use in prod

Changes:
* Adds `DBConfig[Resolver|Provider]` to the `configuration` package,
where the resolver does very little, and the provider executes most of
the actual database SQL queries. For DB-backed configs, all the data
needed to retrieve a config can be found in the `ref` without a separate
key, so the URLs are all simple `db://` stubs that exist only to satisfy
the existing provider and resolver interfaces, which expect URLs to be
passed as keys.
* Refactors the 3 Err types, `isNotFound`, and `translatePGError` out of
the `dal` package into a separate `dalerrors`
* Adds a public getter for the `db` field in `dal.DAL` so that we can
connect to the same DB in the database config resolver/provider
  • Loading branch information
deniseli authored May 29, 2024
1 parent d339228 commit d2e8e1e
Show file tree
Hide file tree
Showing 11 changed files with 429 additions and 3 deletions.
91 changes: 91 additions & 0 deletions backend/controller/dal/dal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/alecthomas/types/optional"
"golang.org/x/sync/errgroup"

"github.com/TBD54566975/ftl/backend/controller/sql"
"github.com/TBD54566975/ftl/backend/controller/sql/sqltest"
ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/schema"
Expand Down Expand Up @@ -400,3 +401,93 @@ func assertEventsEqual(t *testing.T, expected, actual []Event) {
t.Helper()
assert.Equal(t, normaliseEvents(expected), normaliseEvents(actual))
}

func TestModuleConfiguration(t *testing.T) {
ctx := log.ContextWithNewDefaultLogger(context.Background())
conn := sqltest.OpenForTesting(ctx, t)
dal, err := New(ctx, conn)
assert.NoError(t, err)
assert.NotZero(t, dal)

tests := []struct {
TestName string
ModuleSet optional.Option[string]
ModuleGet optional.Option[string]
PresetGlobal bool
}{
{
"SetModuleGetModule",
optional.Some("echo"),
optional.Some("echo"),
false,
},
{
"SetGlobalGetGlobal",
optional.None[string](),
optional.None[string](),
false,
},
{
"SetGlobalGetModule",
optional.None[string](),
optional.Some("echo"),
false,
},
{
"SetModuleOverridesGlobal",
optional.Some("echo"),
optional.Some("echo"),
true,
},
}

b := []byte(`"asdf"`)
for _, test := range tests {
t.Run(test.TestName, func(t *testing.T) {
if test.PresetGlobal {
err := dal.db.SetModuleConfiguration(ctx, optional.None[string](), "configname", []byte(`"qwerty"`))
assert.NoError(t, err)
}
err := dal.db.SetModuleConfiguration(ctx, test.ModuleSet, "configname", b)
assert.NoError(t, err)
gotBytes, err := dal.db.GetModuleConfiguration(ctx, test.ModuleGet, "configname")
assert.NoError(t, err)
assert.Equal(t, b, gotBytes)
err = dal.db.UnsetModuleConfiguration(ctx, test.ModuleGet, "configname")
assert.NoError(t, err)
})
}

t.Run("List", func(t *testing.T) {
sortedList := []sql.ModuleConfiguration{
{
Module: optional.Some("echo"),
Name: "a",
},
{
Module: optional.Some("echo"),
Name: "b",
},
{
Module: optional.None[string](),
Name: "a",
},
}

// Insert entries in a separate order from how they should be returned to
// test sorting logic in the SQL query
err := dal.db.SetModuleConfiguration(ctx, sortedList[1].Module, sortedList[1].Name, []byte(`""`))
assert.NoError(t, err)
err = dal.db.SetModuleConfiguration(ctx, sortedList[2].Module, sortedList[2].Name, []byte(`""`))
assert.NoError(t, err)
err = dal.db.SetModuleConfiguration(ctx, sortedList[0].Module, sortedList[0].Name, []byte(`""`))
assert.NoError(t, err)

gotList, err := dal.db.ListModuleConfiguration(ctx)
assert.NoError(t, err)
for i := range sortedList {
assert.Equal(t, sortedList[i].Module, gotList[i].Module)
assert.Equal(t, sortedList[i].Name, gotList[i].Name)
}
})
}
8 changes: 8 additions & 0 deletions backend/controller/sql/models.go

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

4 changes: 4 additions & 0 deletions backend/controller/sql/querier.go

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

25 changes: 23 additions & 2 deletions backend/controller/sql/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,6 @@ WHERE
fsm = @fsm::schema_ref AND key = @key::TEXT
RETURNING true;


-- name: FailFSMInstance :one
UPDATE fsm_instances
SET
Expand All @@ -612,4 +611,26 @@ SET
status = 'failed'::fsm_status
WHERE
fsm = @fsm::schema_ref AND key = @key::TEXT
RETURNING true;
RETURNING true;

-- name: GetModuleConfiguration :one
SELECT value
FROM module_configuration
WHERE
(module IS NULL OR module = @module)
AND name = @name
ORDER BY module NULLS LAST
LIMIT 1;

-- name: ListModuleConfiguration :many
SELECT *
FROM module_configuration
ORDER BY module, name;

-- name: SetModuleConfiguration :exec
INSERT INTO module_configuration (module, name, value)
VALUES ($1, $2, $3);

-- name: UnsetModuleConfiguration :exec
DELETE FROM module_configuration
WHERE module = @module AND name = @name;
69 changes: 69 additions & 0 deletions backend/controller/sql/queries.sql.go

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

12 changes: 11 additions & 1 deletion backend/controller/sql/schema/001_init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -448,4 +448,14 @@ CREATE TABLE fsm_instances (
CREATE UNIQUE INDEX idx_fsm_instances_fsm_key ON fsm_instances(fsm, key);
CREATE INDEX idx_fsm_instances_status ON fsm_instances(status);

-- migrate:down
CREATE TABLE module_configuration
(
id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
module TEXT, -- If NULL, configuration is global.
name TEXT NOT NULL,
value JSONB NOT NULL,
UNIQUE (module, name)
);

-- migrate:down
52 changes: 52 additions & 0 deletions common/configuration/db_config_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package configuration

import (
"context"
"net/url"

"github.com/TBD54566975/ftl/backend/controller/dal"
"github.com/alecthomas/types/optional"
)

// DBConfigProvider is a configuration provider that stores configuration in its key.
type DBConfigProvider struct {
dal DBConfigProviderDAL
}

type DBConfigProviderDAL interface {
GetModuleConfiguration(ctx context.Context, module optional.Option[string], name string) ([]byte, error)
SetModuleConfiguration(ctx context.Context, module optional.Option[string], name string, value []byte) error
UnsetModuleConfiguration(ctx context.Context, module optional.Option[string], name string) error
}

var _ MutableProvider[Configuration] = DBConfigProvider{}

func NewDBConfigProvider(dal DBConfigProviderDAL) DBConfigProvider {
return DBConfigProvider{
dal: dal,
}
}

func (DBConfigProvider) Role() Configuration { return Configuration{} }
func (DBConfigProvider) Key() string { return "db" }
func (DBConfigProvider) Writer() bool { return true }

func (d DBConfigProvider) Load(ctx context.Context, ref Ref, key *url.URL) ([]byte, error) {
value, err := d.dal.GetModuleConfiguration(ctx, ref.Module, ref.Name)
if err != nil {
return nil, dal.ErrNotFound
}
return value, nil
}

func (d DBConfigProvider) Store(ctx context.Context, ref Ref, value []byte) (*url.URL, error) {
err := d.dal.SetModuleConfiguration(ctx, ref.Module, ref.Name, value)
if err != nil {
return nil, err
}
return &url.URL{Scheme: "db"}, nil
}

func (d DBConfigProvider) Delete(ctx context.Context, ref Ref) error {
return d.dal.UnsetModuleConfiguration(ctx, ref.Module, ref.Name)
}
Loading

0 comments on commit d2e8e1e

Please sign in to comment.