Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

kvdb: add postgres #5366

Merged
merged 3 commits into from
Sep 21, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ jobs:
matrix:
unit_type:
- btcd unit-cover
- unit tags=kvdb_etcd
- unit tags="kvdb_etcd kvdb_postgres"
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you want separate lines for these tags to test them individually.

Copy link
Collaborator

@bhandras bhandras Jul 16, 2021

Choose a reason for hiding this comment

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

Some more context: when we add the tag kvdb_etcd we also run channeldb unit tests on etcd.
The trick is that there's a GetTestBackend() function in kvdb/backend.go and there's a constant behind the tag in kvdb/kvdb_etcd.go and kvdb/kvdb_no_etcd.go that defines which backend to start with GetTestBacked().

Looking at it again I now think having the tags passed together works but it'll still run the channeldb tests on etcd. You may want to fix this such that those tests also run on Postgres.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ran the channeldb tests on postgres and they all pass except for three specialty tests. Two fail because of assumptions about the db path (does not apply to postgres) and one because of panic bubbling that is different in Postgres. Needs a closer look.

My proposal is to do that work in a separate PR where we can also evaluate the additional run time for a test suite that is already long-running. For now, I at least have manual confirmation that the channeldb tests do pass on Postgres. Let me know your thoughts.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Running the postgres tests as a separate suite sounds good to me. If that needs some more work because of special cases I agree it should be done in a separate PR. Knowing they pass (with just three explainable exceptions) is important though.

@joostjager can you create an issue please so we don't forget?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've pushed the code that I used here: #5550. I think this can double as an issue?

Copy link
Member

Choose a reason for hiding this comment

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

Isn't it the case that tacking on this extra tag doesn't actually do anything in practice? I'm guessing Go has some ordering tie-breaker here that causes it to only use the first tag?

In any case I think we want these to run separately so we can catch distinct regressions for both backends.

Copy link
Member

Choose a reason for hiding this comment

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

I ran the channeldb tests on postgres and they all pass except for three specialty tests.

Are these tests still failing?

Copy link
Contributor Author

@joostjager joostjager Aug 16, 2021

Choose a reason for hiding this comment

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

In any case I think we want these to run separately so we can catch distinct regressions for both backends.

They are running separately. There is kvdb/etcd_test.go and kvdb/postgres_test.go. Both build tags are set to have all code available in the test binary.

Are these tests still failing?

Still failing, I haven't continued the work on that PR #5550 above.

- travis-race
steps:
- name: git checkout
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0
github.com/btcsuite/btcwallet/walletdb v1.3.6-0.20210803004036-eebed51155ec
github.com/btcsuite/btcwallet/wtxmgr v1.3.1-0.20210822222949-9b5a201c344c
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f
github.com/davecgh/go-spew v1.1.1
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/go-errors/errors v1.0.1
Expand Down Expand Up @@ -55,7 +55,7 @@ require (
github.com/urfave/cli v1.20.0
go.etcd.io/etcd/client/pkg/v3 v3.5.0
go.etcd.io/etcd/client/v3 v3.5.0
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
golang.org/x/net v0.0.0-20210913180222-943fd674d43e
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210915083310-ed5796bab164 // indirect
Expand Down
138 changes: 136 additions & 2 deletions go.sum

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions kvdb/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const (
// live instance of etcd.
EtcdBackendName = "etcd"

// PostgresBackendName is the name of the backend that should be passed
// into kvdb.Create to initialize a new instance of kvdb.Backend backed
// by a live instance of postgres.
PostgresBackendName = "postgres"
joostjager marked this conversation as resolved.
Show resolved Hide resolved

// DefaultBoltAutoCompactMinAge is the default minimum time that must
// have passed since a bolt database file was last compacted for the
// compaction to be considered again.
Expand Down
3 changes: 3 additions & 0 deletions kvdb/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ require (
github.com/btcsuite/btcwallet/walletdb v1.3.6-0.20210803004036-eebed51155ec
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/btree v1.0.1
github.com/fergusstrange/embedded-postgres v1.7.0
github.com/jackc/pgx/v4 v4.13.0
github.com/lightningnetwork/lnd/healthcheck v1.0.0
github.com/stretchr/testify v1.7.0
go.etcd.io/bbolt v1.3.6
go.etcd.io/etcd/api/v3 v3.5.0
go.etcd.io/etcd/client/pkg/v3 v3.5.0
go.etcd.io/etcd/client/v3 v3.5.0
go.etcd.io/etcd/server/v3 v3.5.0
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4
)

// This replace is for https://github.com/advisories/GHSA-w73w-5m7g-f7qc
Expand Down
145 changes: 141 additions & 4 deletions kvdb/go.sum

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion kvdb/log.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package kvdb

import "github.com/btcsuite/btclog"
import (
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/kvdb/postgres"
)

// log is a logger that is initialized as disabled. This means the package will
// not perform any logging by default until a logger is set.
Expand All @@ -9,4 +12,6 @@ var log = btclog.Disabled
// UseLogger uses a specified Logger to output package logging info.
func UseLogger(logger btclog.Logger) {
log = logger

postgres.UseLogger(log)
}
9 changes: 9 additions & 0 deletions kvdb/postgres/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package postgres

import "time"

// Config holds postgres configuration data.
type Config struct {
Dsn string `long:"dsn" description:"Database connection string."`
Copy link
Member

Choose a reason for hiding this comment

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

Where's dsn come from?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Data Source Name, I thought it was a generic thing in SQL land, but maybe not. ConnStr?

Timeout time.Duration `long:"timeout" description:"Database connection timeout. Set to zero to disable."`
}
241 changes: 241 additions & 0 deletions kvdb/postgres/db.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// +build kvdb_postgres

package postgres
Copy link
Member

Choose a reason for hiding this comment

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

Is anything in this file actually postgres specific? Would replacing the database conn string w/ MYSQL just work out of the box?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No it wouldn't because some of the SQL is db engine specific. Maybe it could be generalized, but this could have a performance impact. Also some special operations like listing tables don't have a generic SQL statement for it afaik


import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"sync"
"time"

"github.com/btcsuite/btcwallet/walletdb"
_ "github.com/jackc/pgx/v4/stdlib"
)

const (
// kvTableName is the name of the table that will contain all the kv
// pairs.
kvTableName = "kv"
)

// KV stores a key/value pair.
type KV struct {
key string
val string
}

// db holds a reference to the postgres connection connection.
type db struct {
// cfg is the postgres connection config.
cfg *Config

// prefix is the table name prefix that is used to simulate namespaces.
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this be a top-level argument in the main config? As it would allow multiple lnd nodes to share the same postgres database.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What is the gain of having multiple lnds write to the same db in diff namespaces? I thought it is safer to keep them isolated. For a cluster of nodes that operate as one, namespace isolation is probably not necessary?

Copy link
Member

Choose a reason for hiding this comment

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

What is the gain of having multiple lnds write to the same db in diff namespaces? I thought it is safer to keep them isolated.

It would make it possible to run multiple lnd nodes with a single database instance vs having to maintain/backup/monitor multiple DB server isntances.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, but there is also the database level in a database instance. The database is something that can be configured in lnd.conf, so it is already possible to point multiple lnd nodes to the same postgres instance. The itests also run like that. Each test creates its own random database in the same postgres instance.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense, had initially missed that!

// We don't use schemas because at least sqlite does not support that.
prefix string

// ctx is the overall context for the database driver.
//
// TODO: This is an anti-pattern that is in place until the kvdb
// interface supports a context.
ctx context.Context

// db is the underlying database connection instance.
db *sql.DB

// lock is the global write lock that ensures single writer.
lock sync.RWMutex

// table is the name of the table that contains the data for all
// top-level buckets that have keys that cannot be mapped to a distinct
// sql table.
table string
}

// Enforce db implements the walletdb.DB interface.
var _ walletdb.DB = (*db)(nil)

// newPostgresBackend returns a db object initialized with the passed backend
// config. If postgres connection cannot be estabished, then returns error.
func newPostgresBackend(ctx context.Context, config *Config, prefix string) (
*db, error) {

if prefix == "" {
return nil, errors.New("empty postgres prefix")
}

dbConn, err := sql.Open("pgx", config.Dsn)
Copy link
Member

Choose a reason for hiding this comment

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

Related to comment above about things being postgres specific or not. Can we just elevate this to a config level value (the driver name)? Would be cool to be able to support everything listed here "out of the box" (traditional asterisk applies ofc lol): https://github.com/golang/go/wiki/SQLDrivers#drivers

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes so adding a new backend isn't that simple. Also locking models are different. Not a problem in this single writer pr, but when switching to serializable the details of the engine do start to matter.

if err != nil {
return nil, err
}

// Compose system table names.
table := fmt.Sprintf(
"%s_%s", prefix, kvTableName,
)

// Execute the create statements to set up a kv table in postgres. Every
// row points to the bucket that it is one via its parent_id field. A
// NULL parent_id means that the key belongs to the upper-most bucket in
// this table. A constraint on parent_id is enforcing referential
// integrity.
//
// Furthermore there is a <table>_p index on parent_id that is required
// for the foreign key constraint.
//
// Finally there are unique indices on (parent_id, key) to prevent the
// same key being present in a bucket more than once (<table>_up and
// <table>_unp). In postgres, a single index wouldn't enforce the unique
// constraint on rows with a NULL parent_id. Therefore two indices are
// defined.
_, err = dbConn.ExecContext(ctx, `
CREATE SCHEMA IF NOT EXISTS public;
CREATE TABLE IF NOT EXISTS public.`+table+`
(
key bytea NOT NULL,
value bytea,
parent_id bigint,
id bigserial PRIMARY KEY,
sequence bigint,
CONSTRAINT `+table+`_parent FOREIGN KEY (parent_id)
REFERENCES public.`+table+` (id)
ON UPDATE NO ACTION
ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS `+table+`_p
ON public.`+table+` (parent_id);

CREATE UNIQUE INDEX IF NOT EXISTS `+table+`_up
ON public.`+table+`
(parent_id, key) WHERE parent_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS `+table+`_unp
ON public.`+table+` (key) WHERE parent_id IS NULL;
`)
if err != nil {
_ = dbConn.Close()

return nil, err
}

backend := &db{
cfg: config,
prefix: prefix,
ctx: ctx,
db: dbConn,
table: table,
}

return backend, nil
}

// getTimeoutCtx gets a timeout context for database requests.
func (db *db) getTimeoutCtx() (context.Context, func()) {
if db.cfg.Timeout == time.Duration(0) {
return db.ctx, func() {}
}

return context.WithTimeout(db.ctx, db.cfg.Timeout)
}

// getPrefixedTableName returns a table name for this prefix (namespace).
func (db *db) getPrefixedTableName(table string) string {
return fmt.Sprintf("%s_%s", db.prefix, table)
}

// catchPanic executes the specified function. If a panic occurs, it is returned
// as an error value.
func catchPanic(f func() error) (err error) {
defer func() {
if r := recover(); r != nil {
err = r.(error)
log.Criticalf("Caught unhandled error: %v", err)
}
}()

err = f()

return
}

// View opens a database read transaction and executes the function f with the
// transaction passed as a parameter. After f exits, the transaction is rolled
// back. If f errors, its error is returned, not a rollback error (if any
// occur). The passed reset function is called before the start of the
// transaction and can be used to reset intermediate state. As callers may
// expect retries of the f closure (depending on the database backend used), the
// reset function will be called before each retry respectively.
func (db *db) View(f func(tx walletdb.ReadTx) error, reset func()) error {
return db.executeTransaction(
func(tx walletdb.ReadWriteTx) error {
return f(tx.(walletdb.ReadTx))
},
reset, true,
)
}

// Update opens a database read/write transaction and executes the function f
// with the transaction passed as a parameter. After f exits, if f did not
// error, the transaction is committed. Otherwise, if f did error, the
// transaction is rolled back. If the rollback fails, the original error
// returned by f is still returned. If the commit fails, the commit error is
// returned. As callers may expect retries of the f closure, the reset function
// will be called before each retry respectively.
func (db *db) Update(f func(tx walletdb.ReadWriteTx) error, reset func()) (err error) {
return db.executeTransaction(f, reset, false)
}

// executeTransaction creates a new read-only or read-write transaction and
// executes the given function within it.
func (db *db) executeTransaction(f func(tx walletdb.ReadWriteTx) error,
reset func(), readOnly bool) error {

reset()

tx, err := newReadWriteTx(db, readOnly)
if err != nil {
return err
}

err = catchPanic(func() error { return f(tx) })
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Errorf("Error rolling back tx: %v", rollbackErr)
}

return err
}

return tx.Commit()
}

// PrintStats returns all collected stats pretty printed into a string.
func (db *db) PrintStats() string {
return "stats not supported by Postgres driver"
}

// BeginReadWriteTx opens a database read+write transaction.
func (db *db) BeginReadWriteTx() (walletdb.ReadWriteTx, error) {
return newReadWriteTx(db, false)
}

// BeginReadTx opens a database read transaction.
func (db *db) BeginReadTx() (walletdb.ReadTx, error) {
return newReadWriteTx(db, true)
}

// Copy writes a copy of the database to the provided writer. This call will
// start a read-only transaction to perform all operations.
// This function is part of the walletdb.Db interface implementation.
func (db *db) Copy(w io.Writer) error {
return errors.New("not implemented")
}

// Close cleanly shuts down the database and syncs all data.
// This function is part of the walletdb.Db interface implementation.
func (db *db) Close() error {
return db.db.Close()
}
56 changes: 56 additions & 0 deletions kvdb/postgres/db_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// +build kvdb_postgres

package postgres
joostjager marked this conversation as resolved.
Show resolved Hide resolved

import (
"testing"
"time"

"github.com/btcsuite/btcwallet/walletdb"
"github.com/btcsuite/btcwallet/walletdb/walletdbtest"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
)

// TestInterface performs all interfaces tests for this database driver.
func TestInterface(t *testing.T) {
f := NewFixture(t)
defer f.Cleanup()

// dbType is the database type name for this driver.
const dbType = "postgres"

ctx := context.Background()
cfg := &Config{
Dsn: testDsn,
}

walletdbtest.TestInterface(t, dbType, ctx, cfg, prefix)
}

// TestPanic tests recovery from panic conditions.
func TestPanic(t *testing.T) {
f := NewFixture(t)
defer f.Cleanup()

d := f.NewBackend()

err := d.(*db).Update(func(tx walletdb.ReadWriteTx) error {
bucket, err := tx.CreateTopLevelBucket([]byte("test"))
require.NoError(t, err)

// Stop database server.
f.Cleanup()

// Keep trying to get data until Get panics because the
// connection is lost.
for i := 0; i < 50; i++ {
Roasbeef marked this conversation as resolved.
Show resolved Hide resolved
bucket.Get([]byte("key"))
time.Sleep(100 * time.Millisecond)
}

return nil
}, func() {})

require.Contains(t, err.Error(), "terminating connection")
}
Loading