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

Dependency context.Context #31

Merged
merged 4 commits into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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 Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
PROJECT := github.com/juju/worker/v3
PROJECT := github.com/juju/worker/v4

.PHONY: check-licence check-go check

Expand Down
2 changes: 1 addition & 1 deletion catacomb/catacomb.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"github.com/juju/errors"
"gopkg.in/tomb.v2"

"github.com/juju/worker/v3"
"github.com/juju/worker/v4"
)

// Catacomb is a variant of tomb.Tomb with its own internal goroutine, designed
Expand Down
4 changes: 2 additions & 2 deletions catacomb/catacomb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (
gc "gopkg.in/check.v1"
"gopkg.in/tomb.v2"

"github.com/juju/worker/v3"
"github.com/juju/worker/v3/catacomb"
"github.com/juju/worker/v4"
"github.com/juju/worker/v4/catacomb"
)

type CatacombSuite struct {
Expand Down
4 changes: 2 additions & 2 deletions catacomb/fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
gc "gopkg.in/check.v1"
"gopkg.in/tomb.v2"

"github.com/juju/worker/v3"
"github.com/juju/worker/v3/catacomb"
"github.com/juju/worker/v4"
"github.com/juju/worker/v4/catacomb"
)

type cleaner interface {
Expand Down
188 changes: 90 additions & 98 deletions dependency/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,49 @@
// 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
independent watchdog if one of its dependencies fails. This characteristic is
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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 <things>, 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 <things>, 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
Expand All @@ -214,17 +210,15 @@ 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,
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
Expand All @@ -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
Loading