Skip to content

[Core] Improve dependency injection for submodules #810

@bryanchriswhite

Description

@bryanchriswhite

Objective

Improve the dependency injection experience for submodules (e.g. P2P's PeerstoreProvider, telemetry's TimeSeriesAgent, etc.). I think this may generally apply to submodules which need to embed the base_modules.InterruptableModule.

Origin Document

Observation made while working on #732. In order to complete the peerstore provider refactor (#804).

When retrieving modules from the bus (via the module registry), they are asserted to the correct interface type by their respective Bus#Get_X_Module() method. In contrast, retrieving submodules from the registry must be done directly (at the time of writing) which requires additional type assertions and boilerplate in each place any submodule is retrieved from the bus.

Goals

  • Support simplification of submodule interfaces (i.e. don't embed Module unless appropriate).
  • Support dependency injection of submodules with a developer experience on par with that of modules.
classDiagram 

class IntegratableModule {
    <<interface>>
    +GetBus() Bus
    +SetBus(bus Bus)
}

class ModuleFactoryWithOptions {
    <<interface>>
    Create(bus Bus, options ...ModuleOption) (Module, error)
}


class Module {
    <<interface>>
}
class InjectableModule {
    <<interface>>
    GetModuleName() string
}
class InterruptableModule {
    <<interface>>
    Start() error
    Stop() error
}

Module --|> InjectableModule
Module --|> IntegratableModule
Module --|> InterruptableModule
Module --|> ModuleFactoryWithOptions

class Submodule {
    <<interface>>
}

Submodule --|> InjectableModule
Submodule --|> IntegratableModule

class exampleSubmodule
class exampleSubmoduleFactory {
    <<interface>>
    Create(...) (exampleSubmodule, error)
}

exampleSubmodule --|> Submodule
exampleSubmodule --|> exampleSubmoduleFactory

exampleModule --|> Module
Loading

Concrete Example

Below, rpcPeerstoreProvider MUST implement Module so that it can traverse the ModuleRegistry dependency injection system that we currently have, as it's used outside of the P2P module in the CLI. This results in it embedding the noop implementations of InterruptableModule and being additionally over-constrained by the InitializableModule#Create() interface method:

classDiagram 

class IntegratableModule {
    <<interface>>
    +GetBus() Bus
    +SetBus(bus Bus)
}

class PeerstoreProvider {
    <<interface>>
    +GetStakedPeerstoreAtHeight(height int) (Peerstore, error)
    +GetUnstakedPeerstore() (Peerstore, error)
}


class rpcPeerstoreProvider

rpcPeerstoreProvider --|> PeerstoreProvider
rpcPeerstoreProvider --|> Module

class Module {
    <<interface>>
}
class InitializableModule {
    <<interface>>
    Create(bus Bus, options ...ModuleOption) (Module, error)
    GetModuleName() string
}
class InterruptableModule {
    <<interface>>
    Start() error
    Stop() error
}

Module --|> InitializableModule
Module --|> IntegratableModule
Module --|> InterruptableModule
Loading

Requiring rpcPeerstoreProvider (the injectee) to implement InitializableModule fosters boilerplate around the respective constructor function and everywhere it's injected. Here is an excerpt from p2p/providers/peerstore_provider/rpc/provider.go:

func NewRPCPeerstoreProvider(options ...modules.ModuleOption) *rpcPeerstoreProvider {
	rpcPSP := &rpcPeerstoreProvider{
		rpcURL: fmt.Sprintf("http://%s:%s", rpcHost, defaults.DefaultRPCPort), // TODO: Make port configurable
	}

	for _, o := range options {
		o(rpcPSP)
	}

	rpcPSP.initRPCClient()

	return rpcPSP
}

func Create(bus modules.Bus, options ...modules.ModuleOption) (modules.Module, error) {
	return new(rpcPeerstoreProvider).Create(bus, options...)
}

func (*rpcPeerstoreProvider) Create(bus modules.Bus, options ...modules.ModuleOption) (modules.Module, error) {
	return NewRPCPeerstoreProvider(options...), nil
}

The Create() function isn't currently used anywhere in the codebase, same goes for the rpcPeerstoreProvider#Create() method, which only serves to satisfy the InitializableModule interface requirement. This increases complexity and reduces readability and maintainability on both the injectee and injector side in my opinion.

Here is an excerpt from p2pModule which illustrates the complexity this design introduces on the injector side (which will present everywhere a submodule is retrieved from the ModuleRegistry):

// setupPeerstoreProvider attempts to retrieve the peerstore provider from the
// bus, if one is registered, otherwise returns a new `persistencePeerstoreProvider`.
func (m *p2pModule) setupPeerstoreProvider() error {
	m.logger.Debug().Msg("setupPeerstoreProvider")
	pstoreProviderModule, err := m.GetBus().GetModulesRegistry().GetModule(peerstore_provider.ModuleName)
	if err != nil {
		m.logger.Debug().Msg("creating new persistence peerstore...")
		pstoreProviderModule = persABP.NewPersistencePeerstoreProvider(m.GetBus())
	} else if pstoreProviderModule != nil {
		m.logger.Debug().Msg("loaded persistence peerstore...")
	}

	pstoreProvider, ok := pstoreProviderModule.(providers.PeerstoreProvider)
	if !ok {
		return fmt.Errorf("unknown peerstore provider type: %T", pstoreProviderModule)
	}
	m.pstoreProvider = pstoreProvider

	return nil
}

I would prefer to be able to do something like this:

classDiagram 

class IntegratableModule {
    <<interface>>
    +GetBus() Bus
    +SetBus(bus Bus)
}

class PeerstoreProvider {
    <<interface>>
    +GetStakedPeerstoreAtHeight(height int) (Peerstore, error)
    +GetUnstakedPeerstore() (Peerstore, error)
}


class rpcPeerstoreProvider

rpcPeerstoreProvider --|> PeerstoreProvider
rpcPeerstoreProvider --|> Submodule
rpcPeerstoreProvider --|> rpcPeerstoreProviderFactory

class Submodule {
    <<interface>>
}
class InjectableModule {
    <<interface>>
    GetModuleName() string
}

class rpcPeerstoreProviderOption
class rpcPeerstoreProviderFactory {
    <<interface>>
    Create(bus Bus, options ...rpcPeerstoreProviderOption) (rpcPeerstoreProvider, error)
}

rpcPeerstoreProviderFactory --* rpcPeerstoreProviderOption

Submodule --|> InjectableModule
Submodule --|> IntegratableModule
Loading
var (
	_ modules.Submodule                    = &rpcPeerstoreProvider{}
	_ peerstore_provider.PeerstoreProvider = &rpcPeerstoreProvider{}
	_ rpcPeerstoreProviderFactory          = &rpcPeerstoreProvider{}
)

type rpcPeerstoreProviderOption func(*rpcPeerstoreProvider)
type rpcPeerstoreProviderFactory = modules.FactoryWithOptions[peerstore_provider.PeerstoreProvider, rpcPeerstoreProviderOption]

func Create(options ...modules.ModuleOption) *rpcPeerstoreProvider {
	return new(rpcPeerstoreProvider).Create(nil, options...)
}

func (*rpcPeerstoreProvider) Create(bus modules.Bus, options ...rpcPeerstoreProviderOption) (peerstore_provider.PeerstoreProvider, error) {
	rpcPSP := &rpcPeerstoreProvider{
		rpcURL: fmt.Sprintf("http://%s:%s", rpcHost, defaults.DefaultRPCPort), // TODO: Make port configurable
	}

	for _, o := range options {
		o(rpcPSP)
	}

	rpcPSP.initRPCClient()

	return rpcPSP
}
// setupPeerstoreProvider attempts to retrieve the peerstore provider from the
// bus, if one is registered, otherwise returns a new `persistencePeerstoreProvider`.
func (m *p2pModule) setupPeerstoreProvider() (err error) {
	m.logger.Debug().Msg("setupPeerstoreProvider")
	m.pstoreProvider, err = m.GetBus().GetPeerstoreProviderSubmodule()
	if err != nil {
		m.logger.Debug().Msg("creating new persistence peerstore...")
		m.pstoreProvider = persABP.NewPersistencePeerstoreProvider(m.GetBus())
	} else {
		m.logger.Debug().Msg("loaded persistence peerstore...")
	}
	return nil
}

Deliverable

  • Add the Submodule interface definition
  • Remove InitializableModule#Create() (unnecessary as of ModuleFactoryWithOptions embedding)
  • Rename InitializableModule to InjectableModule
  • Add retrieval methods for submodules to the Bus inteface (like each module has)
  • Map ModuleRegistry module names to InjectableModules (instead of Modules)

Non-goals / Non-deliverables

  • Unnecessarily refactoring existing submodules
  • Refactoring unrelated module registry or consumer code

General issue deliverables

  • Update the appropriate CHANGELOG(s)
  • Update any relevant local/global README(s)
  • Update relevant source code tree explanations
  • Add or update any relevant or supporting mermaid diagrams

Testing Methodology

  • All tests: make test_all
  • LocalNet: verify a LocalNet is still functioning correctly by following the instructions at docs/development/README.md
  • k8s LocalNet: verify a k8s LocalNet is still functioning correctly by following the instructions here

Creator: @bryanchriswhite

Metadata

Metadata

Labels

code healthNice to have code improvementcoreCore infrastructure - protocol related

Type

No type

Projects

Status

Done

Relationships

None yet

Development

No branches or pull requests

Issue actions