From 6b9c0d4e43d5baad3566c64183c4735cf00cc4be Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Wed, 20 Sep 2023 12:11:34 +0100 Subject: [PATCH] Rename Container to Getter Renaming Container (DI) to Getter to prevent any confusion over what a container is in this context. --- dependency/doc.go | 188 ++++++++++++++++++------------------- dependency/engine.go | 1 - dependency/engine_test.go | 12 +-- dependency/interface.go | 9 +- dependency/testing/stub.go | 2 +- dependency/util.go | 2 +- dependency/util_test.go | 4 +- 7 files changed, 105 insertions(+), 113 deletions(-) diff --git a/dependency/doc.go b/dependency/doc.go index 8b63fa0..8eee909 100644 --- a/dependency/doc.go +++ b/dependency/doc.go @@ -2,42 +2,41 @@ // Licensed under the LGPLv3, see LICENCE file for details. /* - Package dependency exists to address a general problem with shared resources and the management of their lifetimes. Many kinds of software handle these issues with more or less felicity, but it's particularly important that juju (which is a distributed system that needs to be very fault-tolerant) handle them clearly and sanely. -Background +# Background A cursory examination of the various workers run in juju agents (as of 2015-04-20) reveals a distressing range of approaches to the shared resource problem. A sampling of techniques (and their various problems) follows: - * enforce sharing in code structure, either directly via scoping or implicitly + - enforce sharing in code structure, either directly via scoping or implicitly via nested runners (state/api conns; agent config) - * code structure is inflexible, and it enforces strictly nested resource - lifetimes, which are not always adequate. - * just create N of them and hope it works out OK (environs) - * creating N prevents us from, e.g., using a single connection to an environ - and sanely rate-limiting ourselves. - * use filesystem locking across processes (machine execution lock) - * implementation sometimes flakes out, or is used improperly; and multiple - agents *are* a problem anyway, but even if we're all in-process we'll need - some shared machine lock... - * wrap workers to start up only when some condition is met (post-upgrade + - code structure is inflexible, and it enforces strictly nested resource + lifetimes, which are not always adequate. + - just create N of them and hope it works out OK (environs) + - creating N prevents us from, e.g., using a single connection to an environ + and sanely rate-limiting ourselves. + - use filesystem locking across processes (machine execution lock) + - implementation sometimes flakes out, or is used improperly; and multiple + agents *are* a problem anyway, but even if we're all in-process we'll need + some shared machine lock... + - wrap workers to start up only when some condition is met (post-upgrade stability -- itself also a shared resource) - * lifetime-nesting comments apply here again; *and* it makes it harder to - follow the code. - * implement a singleton (lease manager) - * singletons make it *even harder* to figure out what's going on -- they're - basically just fancy globals, and have all the associated problems with, - e.g. deadlocking due to unexpected shutdown order. + - lifetime-nesting comments apply here again; *and* it makes it harder to + follow the code. + - implement a singleton (lease manager) + - singletons make it *even harder* to figure out what's going on -- they're + basically just fancy globals, and have all the associated problems with, + e.g. deadlocking due to unexpected shutdown order. ...but, of course, they all have their various advantages: - * Of the approaches, the first is the most reliable by far. Despite the + - Of the approaches, the first is the most reliable by far. Despite the inflexibility, there's a clear and comprehensible model in play that has yet to cause serious confusion: each worker is created with its resource(s) directly available in code scope, and trusts that it will be restarted by an @@ -45,7 +44,7 @@ sampling of techniques (and their various problems) follows: extremely beneficial and must be preserved; we just need it to be more generally applicable. - * The create-N-Environs approach is valuable because it can be simply (if + - The create-N-Environs approach is valuable because it can be simply (if inelegantly) integrated with its dependent worker, and a changed Environ does not cause the whole dependent to fall over (unless the change is itself bad). The former characteristic is a subtle trap (we shouldn't be baking @@ -55,13 +54,13 @@ sampling of techniques (and their various problems) follows: be unwise to take an approach that led to them being restarted when not necessary. - * The filesystem locking just should not happen -- and we need to integrate the + - The filesystem locking just should not happen -- and we need to integrate the unit and machine agents to eliminate it (and for other reasons too) so we should give some thought to the fact that we'll be shuffling these dependencies around pretty hard in the future. If the approach can make that task easier, then great. - * The singleton is dangerous specifically because its dependency interactions are + - The singleton is dangerous specifically because its dependency interactions are unclear. Absolute clarity of dependencies, as provided by the nesting approaches, is in fact critical; but the sheer convenience of the singleton is alluring, and reminds us that the approach we take must remain easy to use. @@ -77,37 +76,36 @@ However, all of these resources are very different: for a solution that encompas them all, you kinda have to represent them as interface{} at some point, and that's very risky re: clarity. - -Problem +# Problem The package is intended to implement the following developer stories: - * As a developer trying to understand the codebase, I want to know what workers + - As a developer trying to understand the codebase, I want to know what workers are running in an agent at any given time. - * As a developer, I want to be prevented from introducing dependency cycles + - As a developer, I want to be prevented from introducing dependency cycles into my application. - * As a developer, I want to provide a service provided by some worker to one or + - As a developer, I want to provide a service provided by some worker to one or more client workers. - * As a developer, I want to write a service that consumes one or more other + - As a developer, I want to write a service that consumes one or more other workers' services. - * As a developer, I want to choose how I respond to missing dependencies. - * As a developer, I want to be able to inject test doubles for my dependencies. - * As a developer, I want control over how my service is exposed to others. - * As a developer, I don't want to have to typecast my dependencies from + - As a developer, I want to choose how I respond to missing dependencies. + - As a developer, I want to be able to inject test doubles for my dependencies. + - As a developer, I want control over how my service is exposed to others. + - As a developer, I don't want to have to typecast my dependencies from interface{} myself. - * As a developer, I want my service to be restarted if its dependencies change. + - As a developer, I want my service to be restarted if its dependencies change. That last one might bear a little bit of explanation: but I contend that it's the only reliable approach to writing resilient services that compose sanely into a comprehensible system. Consider: - * Juju agents' lifetimes must be assumed to exceed the MTBR of the systems + - Juju agents' lifetimes must be assumed to exceed the MTBR of the systems they're deployed on; you might naively think that hard reboots are "rare"... but they're not. They really are just a feature of the terrain we have to traverse. Therefore every worker *always* has to be capable of picking itself back up from scratch and continuing sanely. That is, we're not imposing a new expectation: we're just working within the existing constraints. - * While some workers are simple, some are decidedly not; when a worker has any + - While some workers are simple, some are decidedly not; when a worker has any more complexity than "none" it is a Bad Idea to mix dependency-management concerns into their core logic: it creates the sort of morass in which subtle bugs thrive. @@ -120,15 +118,14 @@ before they hit users. We'd maybe also like to implement this story: - * As a developer, I want to add and remove groups of workers atomically, e.g. + - As a developer, I want to add and remove groups of workers atomically, e.g. when starting the set of controller workers for a hosted environ; or when starting the set of workers used by a single unit. [NOT DONE] ...but there's no urgent use case yet, and it's not certain to be superior to an engine-nesting approach. - -Solution +# Solution Run a single dependency.Engine at the top level of each agent; express every shared resource, and every worker that uses one, as a dependency.Manifold; and @@ -137,9 +134,9 @@ install them all into the top-level engine. When installed under some name, a dependency.Manifold represents the features of a node in the engine's dependency graph. It lists: - * The names of its dependencies (Inputs). - * How to create the worker representing the resource (Start). - * How (if at all) to expose the resource as a service to other resources that + - The names of its dependencies (Inputs). + - How to create the worker representing the resource (Start). + - How (if at all) to expose the resource as a service to other resources that know it by name (Output). ...and allows the developers of each independent service a common mechanism for @@ -154,56 +151,55 @@ least there's still some observability; but there may also be call to pass actual dependencies down from one engine to another, and that'll demand careful thought. - -Usage +# Usage In each worker package, write a `manifold.go` containing the following: - // ManifoldConfig holds the information necessary to configure the worker - // controlled by a Manifold. - type ManifoldConfig struct { - - // The names of the various dependencies, e.g. - APICallerName string - - // Any other required top-level configuration, e.g. - Period time.Duration - } - - // Manifold returns a manifold that controls the operation of a worker - // responsible for , configured as supplied. - func Manifold(config ManifoldConfig) dependency.Manifold { - // Your code here... - return dependency.Manifold{ - - // * certainly include each of your configured dependency names, - // getResource will only expose them if you declare them here. - Inputs: []string{config.APICallerName, config.MachineLockName}, - - // * certainly include a start func, it will panic if you don't. - Start: func(getResource dependency.GetResourceFunc) (worker.Worker, error) { - // You presumably want to get your dependencies, and you almost - // certainly want to be closed over `config`... - var apicaller base.APICaller - if err := getResource(config.APICallerName, &apicaller); err != nil { - return nil, err - } - return newSomethingWorker(apicaller, config.Period) - }, - - // * output func is not obligatory, and should be skipped if you - // don't know what you'll be exposing or to whom. - // * see `worker/gate`, `worker/util`, and - // `worker/dependency/testing` for examples of output funcs. - // * if you do supply an output func, be sure to document it on the - // Manifold func; for example: - // - // // Manifold exposes Foo and Bar resources, which can be - // // accessed by passing a *Foo or a *Bar in the output - // // parameter of its dependencies' getResouce calls. - Output: nil, - } - } + // ManifoldConfig holds the information necessary to configure the worker + // controlled by a Manifold. + type ManifoldConfig struct { + + // The names of the various dependencies, e.g. + APICallerName string + + // Any other required top-level configuration, e.g. + Period time.Duration + } + + // Manifold returns a manifold that controls the operation of a worker + // responsible for , configured as supplied. + func Manifold(config ManifoldConfig) dependency.Manifold { + // Your code here... + return dependency.Manifold{ + + // * certainly include each of your configured dependency names, + // dependency.Getter will only expose them if you declare them here. + Inputs: []string{config.APICallerName, config.MachineLockName}, + + // * certainly include a start func, it will panic if you don't. + Start: func(ctx context.Context, getter dependency.Getter) (worker.Worker, error) { + // You presumably want to get your dependencies, and you almost + // certainly want to be closed over `config`... + var apicaller base.APICaller + if err := getter.Get(config.APICallerName, &apicaller); err != nil { + return nil, err + } + return newSomethingWorker(apicaller, config.Period) + }, + + // * output func is not obligatory, and should be skipped if you + // don't know what you'll be exposing or to whom. + // * see `worker/gate`, `worker/util`, and + // `worker/dependency/testing` for examples of output funcs. + // * if you do supply an output func, be sure to document it on the + // Manifold func; for example: + // + // // Manifold exposes Foo and Bar resources, which can be + // // accessed by passing a *Foo or a *Bar in the output + // // parameter of its dependencies' getResouce calls. + Output: nil, + } + } ...and take care to construct your manifolds *only* via that function; *all* your dependencies *must* be declared in your ManifoldConfig, and *must* be @@ -214,8 +210,7 @@ consider adding helpers to cmd/jujud/agent/engine, which includes mechanisms for simple definition of manifolds that depend on an API caller; on an agent; or on both. - -Testing +# Testing The `worker/dependency/testing` package, commonly imported as "dt", exposes a `StubResource` that is helpful for testing `Start` funcs in decent isolation, @@ -223,8 +218,7 @@ with mocked dependencies. Tests for `Inputs` and `Output` are generally pretty specific to their precise context and don't seem to benefit much from generalisation. - -Special considerations +# Special considerations The nodes in your *dependency* graph must be acyclic; this does not imply that the *information flow* must be acyclic. Indeed, it is common for separate @@ -250,19 +244,17 @@ particular is effectively just another lock, and it'd be trivial to construct a set of gate-users that can deadlock one another. All the usual considerations when working with locks still apply. - -Concerns and mitigations thereof +# Concerns and mitigations thereof The dependency package will *not* provide the following features: - * Deterministic worker startup. As above, this is a blessing in disguise: if + - Deterministic worker startup. As above, this is a blessing in disguise: if your workers have a problem with this, they're using magical undeclared dependencies and we get to see the inevitable bugs sooner. - * Hand-holding for developers writing Output funcs; the onus is on you to + - Hand-holding for developers writing Output funcs; the onus is on you to document what you expose; produce useful error messages when they supplied with unexpected types via the interface{} param; and NOT to panic. The onus on your clients is only to read your docs and handle the errors you might emit. - */ package dependency diff --git a/dependency/engine.go b/dependency/engine.go index 5d71fea..f2862f3 100644 --- a/dependency/engine.go +++ b/dependency/engine.go @@ -510,7 +510,6 @@ const ( // loop goroutine; waits for worker completion; and communicates any error encountered // back to the loop goroutine. It must not be run on the loop goroutine. func (engine *Engine) runWorker(name string, delay time.Duration, start StartFunc, snapshot *snapshot) { - startAfterDelay := func() (worker.Worker, error) { // NOTE: the context will expire *after* the worker is started. // This is tolerable because diff --git a/dependency/engine_test.go b/dependency/engine_test.go index 448cf1c..03f257a 100644 --- a/dependency/engine_test.go +++ b/dependency/engine_test.go @@ -167,7 +167,7 @@ func (s *EngineSuite) TestStartGetUndeclaredName(c *gc.C) { // Install another task with an undeclared dependency on the started task. done := make(chan struct{}) err = engine.Install("other-task", dependency.Manifold{ - Start: func(ctx context.Context, container dependency.Container) (worker.Worker, error) { + Start: func(ctx context.Context, container dependency.Getter) (worker.Worker, error) { err := container.Get("some-task", nil) c.Check(errors.Is(err, dependency.ErrMissing), jc.IsTrue) c.Check(err, gc.ErrorMatches, `"some-task" not declared: dependency not available`) @@ -209,7 +209,7 @@ func (s *EngineSuite) testStartGet(c *gc.C, outErr error) { done := make(chan struct{}) err = engine.Install("other-task", dependency.Manifold{ Inputs: []string{"some-task"}, - Start: func(ctx context.Context, container dependency.Container) (worker.Worker, error) { + Start: func(ctx context.Context, container dependency.Getter) (worker.Worker, error) { err := container.Get("some-task", &target) // Check the result from some-task's Output func matches what we expect. c.Check(err, gc.Equals, outErr) @@ -241,7 +241,7 @@ func (s *EngineSuite) TestStartAbortOnEngineKill(c *gc.C) { s.fix.run(c, func(engine *dependency.Engine) { starts := make(chan struct{}, 1000) manifold := dependency.Manifold{ - Start: func(ctx context.Context, container dependency.Container) (worker.Worker, error) { + Start: func(ctx context.Context, container dependency.Getter) (worker.Worker, error) { starts <- struct{}{} select { case <-ctx.Done(): @@ -274,7 +274,7 @@ func (s *EngineSuite) TestStartAbortOnDependencyChange(c *gc.C) { starts := make(chan struct{}, 1000) manifold := dependency.Manifold{ Inputs: []string{"parent"}, - Start: func(ctx context.Context, container dependency.Container) (worker.Worker, error) { + Start: func(ctx context.Context, container dependency.Getter) (worker.Worker, error) { starts <- struct{}{} select { case <-ctx.Done(): @@ -496,7 +496,7 @@ func (s *EngineSuite) TestErrMissing(c *gc.C) { // Start a dependent that always complains ErrMissing. mh2 := newManifoldHarness("some-task") manifold := mh2.Manifold() - manifold.Start = func(_ context.Context, _ dependency.Container) (worker.Worker, error) { + manifold.Start = func(_ context.Context, _ dependency.Getter) (worker.Worker, error) { mh2.starts <- struct{}{} return nil, errors.Trace(dependency.ErrMissing) } @@ -595,7 +595,7 @@ func (s *EngineSuite) TestFilterStartError(c *gc.C) { filterErr := errors.New("mew hiss") err := engine.Install("task", dependency.Manifold{ - Start: func(_ context.Context, _ dependency.Container) (worker.Worker, error) { + Start: func(_ context.Context, _ dependency.Getter) (worker.Worker, error) { return nil, startErr }, Filter: func(in error) error { diff --git a/dependency/interface.go b/dependency/interface.go index e80047f..537b744 100644 --- a/dependency/interface.go +++ b/dependency/interface.go @@ -83,10 +83,11 @@ type Manifold struct { // Manifolds conveniently represents several Manifolds. type Manifolds map[string]Manifold -// Container represents the situation in which a StartFunc is running. -// A Container should not be used outside its StartFunc; attempts to do so +// Getter represents a way to request named dependencies from within a +// StartFunc. +// A Getter should not be used outside its StartFunc; attempts to do so // will have undefined results. -type Container interface { +type Getter interface { // Get returns an indication of whether a named dependency can be // satisfied. In particular: // @@ -104,7 +105,7 @@ type Container interface { // be taken from the supplied GetResourceFunc; if no worker can be started due // to unmet dependencies, it should return ErrMissing, in which case it will // not be called again until its declared inputs change. -type StartFunc func(context.Context, Container) (worker.Worker, error) +type StartFunc func(context.Context, Getter) (worker.Worker, error) // FilterFunc is an error conversion function for errors returned from workers // or StartFuncs. diff --git a/dependency/testing/stub.go b/dependency/testing/stub.go index b9fabd8..9b7afb1 100644 --- a/dependency/testing/stub.go +++ b/dependency/testing/stub.go @@ -51,7 +51,7 @@ func NewStubResources(raw map[string]interface{}) StubResources { type StubResources map[string]StubResource // Context returns a dependency.Context that never aborts, backed by resources. -func (resources StubResources) Context() dependency.Container { +func (resources StubResources) Context() dependency.Getter { return &Context{ resources: resources, } diff --git a/dependency/util.go b/dependency/util.go index da819e6..baa40ca 100644 --- a/dependency/util.go +++ b/dependency/util.go @@ -84,7 +84,7 @@ func (v validator) visit(node string) error { // may have surprising effects. func SelfManifold(engine *Engine) Manifold { return Manifold{ - Start: func(_ context.Context, _ Container) (worker.Worker, error) { + Start: func(_ context.Context, _ Getter) (worker.Worker, error) { return engine, nil }, Output: func(in worker.Worker, out interface{}) error { diff --git a/dependency/util_test.go b/dependency/util_test.go index 4342cbe..28cd2a8 100644 --- a/dependency/util_test.go +++ b/dependency/util_test.go @@ -125,7 +125,7 @@ func (mh *manifoldHarness) Manifold() dependency.Manifold { } } -func (mh *manifoldHarness) start(ctx context.Context, container dependency.Container) (worker.Worker, error) { +func (mh *manifoldHarness) start(ctx context.Context, container dependency.Getter) (worker.Worker, error) { mh.startAttempts <- struct{}{} if mh.startError != nil { return nil, mh.startError @@ -221,7 +221,7 @@ func (w *minimalWorker) Report() map[string]interface{} { } } -func startMinimalWorker(_ dependency.Container) (worker.Worker, error) { +func startMinimalWorker(_ dependency.Getter) (worker.Worker, error) { w := &minimalWorker{} w.tomb.Go(func() error { <-w.tomb.Dying()