diff --git a/cmd/curio/guidedsetup/guidedsetup.go b/cmd/curio/guidedsetup/guidedsetup.go index 3874da882..34bdd8319 100644 --- a/cmd/curio/guidedsetup/guidedsetup.go +++ b/cmd/curio/guidedsetup/guidedsetup.go @@ -202,7 +202,7 @@ type MigrationData struct { selectTemplates *promptui.SelectTemplates MinerConfigPath string DB *harmonydb.DB - HarmonyCfg config.HarmonyDB + HarmonyCfg harmonydb.Config MinerID address.Address full api.Chain cctx *cli.Context diff --git a/deps/config/dynamic.go b/deps/config/dynamic.go new file mode 100644 index 000000000..2363e005e --- /dev/null +++ b/deps/config/dynamic.go @@ -0,0 +1,120 @@ +package config + +import ( + "context" + "fmt" + "reflect" + "strings" + "sync" + "time" + + "github.com/BurntSushi/toml" + "github.com/filecoin-project/curio/harmony/harmonydb" + logging "github.com/ipfs/go-log/v2" +) + +var logger = logging.Logger("config-dynamic") +var DynamicMx sync.RWMutex + +type Dynamic[T any] struct { + Value T +} + +func NewDynamic[T any](value T) *Dynamic[T] { + return &Dynamic[T]{Value: value} +} + +func (d *Dynamic[T]) Set(value T) { + DynamicMx.Lock() + defer DynamicMx.Unlock() + d.Value = value +} + +func (d *Dynamic[T]) Get() T { + DynamicMx.RLock() + defer DynamicMx.RUnlock() + return d.Value +} + +func (d *Dynamic[T]) UnmarshalText(text []byte) error { + DynamicMx.Lock() + defer DynamicMx.Unlock() + return toml.Unmarshal(text, d.Value) +} + +type cfgRoot struct { + db *harmonydb.DB + layers []string + treeCopy *CurioConfig +} + +func EnableChangeDetection(db *harmonydb.DB, obj *CurioConfig, layers []string) error { + r := &cfgRoot{db: db, treeCopy: obj, layers: layers} + err := r.copyWithOriginalDynamics(obj) + if err != nil { + return err + } + go r.changeMonitor() + return nil +} + +// copyWithOriginalDynamics copies the original dynamics from the original object to the new object. +func (r *cfgRoot) copyWithOriginalDynamics(orig *CurioConfig) error { + typ := reflect.TypeOf(orig) + if typ.Kind() != reflect.Struct { + return fmt.Errorf("expected struct, got %s", typ.Kind()) + } + result := reflect.New(typ) + // recursively walk the struct tree, and copy the dynamics from the original object to the new object. + var walker func(orig, result reflect.Value) + walker = func(orig, result reflect.Value) { + for i := 0; i < orig.NumField(); i++ { + field := orig.Field(i) + if field.Kind() == reflect.Struct { + walker(field, result.Field(i)) + } else if field.Kind() == reflect.Ptr { + walker(field.Elem(), result.Field(i).Elem()) + } else if field.Kind() == reflect.Interface { + walker(field.Elem(), result.Field(i).Elem()) + } else { + result.Field(i).Set(field) + } + } + } + walker(reflect.ValueOf(orig), result) + r.treeCopy = result.Interface().(*CurioConfig) + return nil +} + +func (r *cfgRoot) changeMonitor() { + lastTimestamp := time.Now().Add(-30 * time.Second) // plenty of time for start-up + + for { + time.Sleep(30 * time.Second) + configCount := 0 + err := r.db.QueryRow(context.Background(), `SELECT COUNT(*) FROM harmony_config WHERE timestamp > $1 AND title IN ($2)`, lastTimestamp, strings.Join(r.layers, ",")).Scan(&configCount) + if err != nil { + logger.Errorf("error selecting configs: %s", err) + continue + } + if configCount == 0 { + continue + } + lastTimestamp = time.Now() + + // 1. get all configs + configs, err := GetConfigs(context.Background(), r.db, r.layers) + if err != nil { + logger.Errorf("error getting configs: %s", err) + continue + } + + // 2. lock "dynamic" mutex + func() { + DynamicMx.Lock() + defer DynamicMx.Unlock() + ApplyLayers(context.Background(), r.treeCopy, configs) + }() + DynamicMx.Lock() + } +} diff --git a/deps/config/load.go b/deps/config/load.go index a307c3f72..6eefb45cf 100644 --- a/deps/config/load.go +++ b/deps/config/load.go @@ -2,6 +2,8 @@ package config import ( "bytes" + "context" + "errors" "fmt" "io" "math/big" @@ -14,9 +16,11 @@ import ( "unicode" "github.com/BurntSushi/toml" + "github.com/filecoin-project/curio/harmony/harmonydb" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/kelseyhightower/envconfig" + "github.com/samber/lo" "golang.org/x/xerrors" ) @@ -564,3 +568,74 @@ func FixTOML(newText string, cfg *CurioConfig) error { } return nil } + +func LoadConfigWithUpgrades(text string, curioConfigWithDefaults *CurioConfig) (toml.MetaData, error) { + // allow migration from old config format that was limited to 1 wallet setup. + newText := strings.Join(lo.Map(strings.Split(text, "\n"), func(line string, _ int) string { + if strings.EqualFold(line, "[addresses]") { + return "[[addresses]]" + } + return line + }), "\n") + + err := FixTOML(newText, curioConfigWithDefaults) + if err != nil { + return toml.MetaData{}, err + } + + return toml.Decode(newText, &curioConfigWithDefaults) +} + +type ConfigText struct { + Title string + Config string +} + +// GetConfigs returns the configs in the order of the layers +func GetConfigs(ctx context.Context, db *harmonydb.DB, layers []string) ([]ConfigText, error) { + inputMap := map[string]int{} + for i, layer := range layers { + inputMap[layer] = i + } + + layers = append([]string{"base"}, layers...) // Always stack on top of "base" layer + + var configs []ConfigText + err := db.Select(ctx, &configs, `SELECT title, config FROM harmony_config WHERE title IN ($1)`, strings.Join(layers, ",")) + if err != nil { + return nil, err + } + result := make([]ConfigText, len(layers)) + for _, config := range configs { + index, ok := inputMap[config.Title] + if !ok { + if config.Title == "base" { + return nil, errors.New(`curio defaults to a layer named 'base'. + Either use 'migrate' command or edit a base.toml and upload it with: curio config set base.toml`) + + } + return nil, fmt.Errorf("missing layer %s", config.Title) + } + result[index] = config + } + return result, nil +} + +func ApplyLayers(ctx context.Context, curioConfig *CurioConfig, layers []ConfigText) error { + have := []string{} + for _, layer := range layers { + meta, err := LoadConfigWithUpgrades(layer.Config, curioConfig) + if err != nil { + return fmt.Errorf("could not read layer, bad toml %s: %w", layer, err) + } + for _, k := range meta.Keys() { + have = append(have, strings.Join(k, " ")) + } + logger.Debugf("Using layer %s, config %v", layer, curioConfig) + } + _ = have // FUTURE: verify that required fields are here. + // If config includes 3rd-party config, consider JSONSchema as a way that + // 3rd-parties can dynamically include config requirements and we can + // validate the config. Because of layering, we must validate @ startup. + return nil +} diff --git a/deps/config/old_lotus_miner.go b/deps/config/old_lotus_miner.go index 53bb84879..043b99d8f 100644 --- a/deps/config/old_lotus_miner.go +++ b/deps/config/old_lotus_miner.go @@ -5,6 +5,7 @@ import ( "github.com/ipfs/go-cid" + "github.com/filecoin-project/curio/harmony/harmonydb" "github.com/filecoin-project/go-state-types/big" "github.com/filecoin-project/go-state-types/builtin" "github.com/filecoin-project/go-state-types/network" @@ -14,27 +15,6 @@ import ( "github.com/filecoin-project/lotus/chain/types" ) -type HarmonyDB struct { - // HOSTS is a list of hostnames to nodes running YugabyteDB - // in a cluster. Only 1 is required - Hosts []string - - // The Yugabyte server's username with full credentials to operate on Lotus' Database. Blank for default. - Username string - - // The password for the related username. Blank for default. - Password string - - // The database (logical partition) within Yugabyte. Blank for default. - Database string - - // The port to find Yugabyte. Blank for default. - Port string - - // Load Balance the connection over multiple nodes - LoadBalance bool -} - // StorageMiner is a miner config type StorageMiner struct { Common @@ -49,7 +29,7 @@ type StorageMiner struct { Addresses MinerAddressConfig DAGStore DAGStoreConfig - HarmonyDB HarmonyDB + HarmonyDB harmonydb.Config } type DAGStoreConfig struct { @@ -683,7 +663,7 @@ func DefaultStorageMiner() *StorageMiner { MaxConcurrentUnseals: 5, GCInterval: time.Minute, }, - HarmonyDB: HarmonyDB{ + HarmonyDB: harmonydb.Config{ Hosts: []string{"127.0.0.1"}, Username: "yugabyte", Password: "yugabyte", diff --git a/deps/deps.go b/deps/deps.go index 1912b3da5..18629a674 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -62,7 +62,7 @@ var log = logging.Logger("curio/deps") func MakeDB(cctx *cli.Context) (*harmonydb.DB, error) { // #1 CLI opts fromCLI := func() (*harmonydb.DB, error) { - dbConfig := config.HarmonyDB{ + dbConfig := harmonydb.Config{ Username: cctx.String("db-user"), Password: cctx.String("db-password"), Hosts: strings.Split(cctx.String("db-host"), ","), @@ -261,7 +261,7 @@ func (deps *Deps) PopulateRemainingDeps(ctx context.Context, cctx *cli.Context, } if deps.EthClient == nil { - deps.EthClient = lazy.MakeLazy[*ethclient.Client](func() (*ethclient.Client, error) { + deps.EthClient = lazy.MakeLazy(func() (*ethclient.Client, error) { cfgApiInfo := deps.Cfg.Apis.ChainApiInfo if v := os.Getenv("FULLNODE_API_INFO"); v != "" { cfgApiInfo = []string{v} @@ -419,20 +419,7 @@ func sealProofType(maddr dtypes.MinerAddress, fnapi api.Chain) (abi.RegisteredSe } func LoadConfigWithUpgrades(text string, curioConfigWithDefaults *config.CurioConfig) (toml.MetaData, error) { - // allow migration from old config format that was limited to 1 wallet setup. - newText := strings.Join(lo.Map(strings.Split(text, "\n"), func(line string, _ int) string { - if strings.EqualFold(line, "[addresses]") { - return "[[addresses]]" - } - return line - }), "\n") - - err := config.FixTOML(newText, curioConfigWithDefaults) - if err != nil { - return toml.MetaData{}, err - } - - return toml.Decode(newText, &curioConfigWithDefaults) + return config.LoadConfigWithUpgrades(text, curioConfigWithDefaults) } func GetConfig(ctx context.Context, layers []string, db *harmonydb.DB) (*config.CurioConfig, error) { @@ -442,38 +429,25 @@ func GetConfig(ctx context.Context, layers []string, db *harmonydb.DB) (*config. } curioConfig := config.DefaultCurioConfig() - have := []string{} - layers = append([]string{"base"}, layers...) // Always stack on top of "base" layer - for _, layer := range layers { - text := "" - err := db.QueryRow(ctx, `SELECT config FROM harmony_config WHERE title=$1`, layer).Scan(&text) - if err != nil { - if strings.Contains(err.Error(), pgx.ErrNoRows.Error()) { - return nil, fmt.Errorf("missing layer '%s' ", layer) - } - if layer == "base" { - return nil, errors.New(`curio defaults to a layer named 'base'. - Either use 'migrate' command or edit a base.toml and upload it with: curio config set base.toml`) - } - return nil, fmt.Errorf("could not read layer '%s': %w", layer, err) - } - - meta, err := LoadConfigWithUpgrades(text, curioConfig) - if err != nil { - return curioConfig, fmt.Errorf("could not read layer, bad toml %s: %w", layer, err) - } - for _, k := range meta.Keys() { - have = append(have, strings.Join(k, " ")) - } - log.Debugw("Using layer", "layer", layer, "config", curioConfig) + err = ApplyLayers(ctx, db, curioConfig, layers) + if err != nil { + return nil, err + } + err = config.EnableChangeDetection(db, curioConfig, layers) + if err != nil { + return nil, err } - _ = have // FUTURE: verify that required fields are here. - // If config includes 3rd-party config, consider JSONSchema as a way that - // 3rd-parties can dynamically include config requirements and we can - // validate the config. Because of layering, we must validate @ startup. return curioConfig, nil } +func ApplyLayers(ctx context.Context, db *harmonydb.DB, curioConfig *config.CurioConfig, layers []string) error { + configs, err := config.GetConfigs(ctx, db, layers) + if err != nil { + return err + } + return config.ApplyLayers(ctx, curioConfig, configs) +} + func updateBaseLayer(ctx context.Context, db *harmonydb.DB) error { _, err := db.BeginTransaction(ctx, func(tx *harmonydb.Tx) (commit bool, err error) { // Get existing base from DB @@ -631,7 +605,7 @@ func GetAPI(ctx context.Context, cctx *cli.Context) (*harmonydb.DB, *config.Curi return nil, nil, nil, nil, nil, err } - ethClient := lazy.MakeLazy[*ethclient.Client](func() (*ethclient.Client, error) { + ethClient := lazy.MakeLazy(func() (*ethclient.Client, error) { return GetEthClient(cctx, cfgApiInfo) }) diff --git a/harmony/harmonydb/harmonydb.go b/harmony/harmonydb/harmonydb.go index 31f08bf4e..b83986dcf 100644 --- a/harmony/harmonydb/harmonydb.go +++ b/harmony/harmonydb/harmonydb.go @@ -21,8 +21,6 @@ import ( "github.com/yugabyte/pgx/v5/pgconn" "github.com/yugabyte/pgx/v5/pgxpool" "golang.org/x/xerrors" - - "github.com/filecoin-project/curio/deps/config" ) type ITestID string @@ -43,11 +41,32 @@ type DB struct { var logger = logging.Logger("harmonydb") +type Config struct { + // HOSTS is a list of hostnames to nodes running YugabyteDB + // in a cluster. Only 1 is required + Hosts []string + + // The Yugabyte server's username with full credentials to operate on Lotus' Database. Blank for default. + Username string + + // The password for the related username. Blank for default. + Password string + + // The database (logical partition) within Yugabyte. Blank for default. + Database string + + // The port to find Yugabyte. Blank for default. + Port string + + // Load Balance the connection over multiple nodes + LoadBalance bool +} + // NewFromConfig is a convenience function. // In usage: // // db, err := NewFromConfig(config.HarmonyDB) // in binary init -func NewFromConfig(cfg config.HarmonyDB) (*DB, error) { +func NewFromConfig(cfg Config) (*DB, error) { return New( cfg.Hosts, cfg.Username, diff --git a/harmony/harmonydb/sql/20250926-harmony_config_timestamp.sql b/harmony/harmonydb/sql/20250926-harmony_config_timestamp.sql new file mode 100644 index 000000000..05e025097 --- /dev/null +++ b/harmony/harmonydb/sql/20250926-harmony_config_timestamp.sql @@ -0,0 +1 @@ +ALTER TABLE harmony_config ADD COLUMN timestamp TIMESTAMP NOT NULL DEFAULT NOW(); \ No newline at end of file