Binding lifetimes
- Comparing
- v1 to v2
- Current
- Read the document
Abstract
We will replace the shared flag on a binding with a lifetime of
three states: Process, resolved once and held until the worker restarts; Cycle, resolved once per
cycle and discarded when the cycle closes; and Transient, a new instance on every resolution.
The container will open and close cycles for whatever drives it, and dispose of cycle instances that hold resources when
a cycle closes. Where a longer-lived object needs an instance from the current cycle, the container will inject a
provider.
Motivation
The container records whether a binding is shared, and nothing else about how long an instance lasts. A shared instance is cached for the life of the container.
The panel runs as a long-lived worker, as ADR-0009 decides. When a PHP process lasted one request, a shared instance lasted one request. In a worker, the same binding lasts until the worker restarts. The lifetime of every binding changed when the runtime was chosen, without any binding changing.
There is no way to declare one instance per request, discarded afterwards, which anything holding the identity of a request needs. The same need arises for a job taken by a queue worker, and for a tick of the scheduler.
Proposal
Lifetimes
Every resolution will have a lifetime, one of the three cases of
the Lifetime enum:
| Lifetime | Duration | Cached |
|---|---|---|
Process |
Resolved once, held until the worker restarts. | In the strong cache, or the weak cache if liminal. |
Cycle |
Resolved once per cycle, discarded when the cycle closes. | In the cycle cache. |
Transient |
A new instance on every resolution. | Never. |
A resolution's lifetime will be decided in this order:
- A lifetime declared on its binding. A binding holding an instance declares
Process. AOtherwise, a lifetime declaredby its class,withPerProcessorPerCycleby the requested class, after alias normalisation, or by the binding's concrete class, the two classes RFC-0006 reads class-level attributes from. Where the two classes declare different lifetimes, or one class carries both attributes, the resolution throwsLifetimeException.- Otherwise,
Processfor a class with a binding, andTransientfor a class without one. - A liminal resolution whose lifetime would be
TransientisProcess, as described under Liminality.
Declaring a lifetime
$registry->bind(ServerRepository::class)->to(SqlServerRepository::class); // Process
$registry->bind(CurrentUser::class)->using($factory)->lifetime(Lifetime::Cycle); // Cycle
$registry->bind(Report::class)->transient(); // Transient
| Builder method | Effect |
|---|---|
lifetime(Lifetime $lifetime) |
Declares the binding's lifetime. |
transient() |
Declares the binding Transient. It replaces notShared(). |
disposeUsing(Closure $callback) |
Declares how an instance of a Cycle binding is disposed, as described under Disposal. |
BindingBuilder and Binding will carry the lifetime of a binding
and its disposal callback in place of the shared flag. The shared flag will be removed, not kept alongside the lifetime.
A binding holding an instance will always be Process, since its instance is the result of every resolution. Declaring
Cycle or Transient on one will throw LifetimeException when the binding is built from its builder, whatever order
the builder's methods were called in.
A class will declare its own lifetime with an attribute, which will apply whether or not it has a binding, unless its binding declares one:
#[PerCycle]
final class CurrentUser {}
#[PerProcess]
final class GameCatalogue {}
An abstract and its concrete class that both declare a lifetime will have to declare the same one. A binding's declared lifetime overrides the attributes of both classes, so the binding is where an implementation is given a lifetime different from the one its abstract declares:
$registry->bind(PermissionChecker::class)->to(CachingPermissionChecker::class)->lifetime(Lifetime::Cycle);
PerProcess and PerCycle will be markers tested only for presence, memoised with the other class attributes, as
ADR-0014 decides. There will be no attribute for
Transient, since a class with no binding will be Transient already.
Liminality
Liminality will apply to the Process lifetime alone:
- A liminal
Processresolution will be held in the weak cache. - A liminal resolution that would otherwise be
Transientwill be held weakly asProcess. - A liminal resolution of a
Cyclebinding or class will stayCycle, and its liminality will be ignored.
There will be no weak cycle cache. A weakly held instance lasts as long as something else holds it, and a cycle instance will last until its cycle closes. Combining them would give whichever ends first, and each bound would make the other redundant.
Cycles
| Method | Effect |
|---|---|
openCycle(): void |
Opens a cycle. Throws if one is already open. |
closeCycle(): void |
Disposes of the cycle's instances, discards them, and closes the cycle. Throws if none is open. |
inCycle(): bool |
Returns whether a cycle is open. |
The container will not know what a cycle represents. Whatever drives it will open and close cycles, such as a worker serving HTTP around each request, a queue worker around each job, or a scheduler around each tick.
Cycles will not nest. Resolving a Cycle lifetime with no cycle open will throw, rather than
return an instance with the wrong identity. The check will be made when the lifetime is decided, so a lazy resolution of
a Cycle lifetime will throw when it is requested with no cycle open, and its
lazy proxy will throw when it is first used with none open.
Caches
The container will hold three caches of the instance cache type described in RFC-0006:
private InstanceCache $instances = InstanceCache::strong();
private InstanceCache $liminalInstances = InstanceCache::weak();
private InstanceCache $cycleInstances = InstanceCache::strong();
One method will select the cache, and both reading and writing will call it:
private function cacheFor(bool $liminal, Lifetime $lifetime): ?InstanceCache
{
return match (true) {
$lifetime === Lifetime::Transient => null,
$lifetime === Lifetime::Cycle => $this->cycleInstances,
$liminal => $this->liminalInstances,
default => $this->instances,
};
}
In the resolution steps of RFC-0006, the
lifetime will be decided alongside liminality. The cached instance step will read from the
cache selected for both, and the sharing step will write to it. A Transient resolution will read from and write to no
cache. The lifetime passed to cacheFor() will be the one decided after liminality is applied, so a liminal resolution
never reaches it as Transient.
Closing a cycle will replace the cycle cache with a new one once its instances are disposed of,
so they will be discarded as one object rather than removed one by one. InstanceCache itself will not change.
Ghost objects
A dependency resolved as a ghost object will follow its
lifetime. A ghost becomes the instance itself once initialised, as
RFC-0001 describes, so it can be cached as the instance before it is
initialised. GhostResolver will decide the lifetime as for a resolution of the same class
and name or qualifier, and use the cache selected for it:
- An instance already in that cache will be injected as it is, with no ghost created.
- Otherwise the ghost will be created and put in that cache at once, so a later resolution of the same class returns
the same object: for the rest of the cycle for a
Cyclelifetime, and until the worker restarts forProcess. - A
Transientlifetime will create a new ghost each time, cached nowhere.
A ghost will be added to the disposal list when it is initialised, after the dependencies its constructor resolves, rather than when it is created. A ghost never initialised will not be disposed of.
A process instance depending on a cycle instance
A Process object whose constructor takes a Cycle instance will resolve it once, in the first
cycle, and hold it for the life of the worker, so every later cycle will see the first cycle's
instance. A lazy proxy or ghost object of a Cycle instance
instance will have the same result: it will resolve resolve
once, in whichever cycle first uses it. So will a ghost object held by a Process
object, which is initialised once, in whichever cycle first uses it.
The container will not reject this. A longer-lived object that needs an instance from the current cycle will take a provider instead.
Providers
A provider will implement the Provider contract:
interface Provider
{
public function get(): object;
}
final class AuditLogger
{
public function __construct(
#[Provide(CurrentUser::class)] private Provider $currentUser,
) {}
public function log(string $event): void
{
$user = $this->currentUser->get();
}
}
Provide will be a resolvable attribute naming the class to provide, and
ProviderResolver its resolver, registered alongside GhostResolver, as
RFC-0001 describes. The resolver will build a Provider for the class and
inject it, so the consuming class never sees the container.
Provider::get() will resolve the class each time it is called, against whichever cycle is open
then, so nothing will be captured. PHP has no generics, so it will return object; Provider<CurrentUser> will exist
only as a docblock for static analysis, and the attribute will be the source of truth. A test will be able to substitute
its own Provider with no container involved.
ProviderResolver will check, when it builds the provider, that the provided class has a
binding or can be constructed, without resolving it, and will throw if not. A broken binding
will therefore fail when the consuming class is built, not on the first call to get().
Providers will be for an object needing an instance with a shorter lifetime than its own. They will not be a general way to defer resolution.
Disposal
A Cycle instance holding a resource, such as an open transaction, a buffered log writer or an unsaved session, will be
disposed of when its cycle closes. Disposal will be declared in
either of two ways:
interface Disposable
{
public function dispose(): void;
}
$registry->bind(Session::class)
->lifetime(Lifetime::Cycle)
->disposeUsing(fn (Session $session) => $session->save());
Disposable, implemented by the class, will suit a class that knows what it holds, with or without a binding.disposeUsing(), declared on the binding, will suit a class that cannot or should not implement a container contract, such as one from a third-party library. The callback will receive the instance. Where a binding declares a callback, the callback will be used instead ofdispose().
disposeUsing() will only be valid on a binding that declares the Cycle binding, and declaring it on any other
lifetime itself.
Declaring it on a binding that does not will throwthrow LifetimeException when the binding is built from its builder, even
where a PerCycle attribute would make its resolutions Cycle. A class that disposes of itself, whatever declares its
lifetime, will implement Disposable.
Only Cycle instances will be disposed of. Process and Transient instances will not be, even when they are
Disposable or created during a cycle.
The container will keep a disposal list for the open cycle, separate from the cycle
cache. A Cycle instance that is Disposable, or whose binding declares a callback, will be appended to it once
constructed, and every other instance will be left off it. A ghost object is constructed
when it is initialised. A dependency resolved eagerly is constructed before whatever depends on it, so it will appear
earlier in the list. A dependency constructed later, through a lazy proxy or a ghost
object, will appear after whatever holds it.
When a cycle closes:
- The cycle counts as closing, so resolving a
Cyclelifetime throws. - Every instance
created duringon thecycle that is Disposable, or whose binding declares a callback,disposal list isdisposeddisposed of,in the reverse offrom theorderlast appended to theinstances were created. A dependency is created before whatever depends on itfirst, sodependents are disposedan instance is disposed of beforetheir dependenciesthe dependencies it resolved eagerly, and after any constructed later through a lazy proxy or a ghost. - Every disposal runs, even when one throws.
- The cycle cache is
replacedreplaced, the disposal list is dropped, and the cycle closes. - If any disposal threw, a
DisposalExceptioncarrying every failure is thrown.
Process instances will not be disposed of when a cycle closes.
Errors
| Exception | Extends | Thrown when |
|---|---|---|
LifetimeException |
LogicException |
A cycle is opened while one is open, closed while none is open, or a Cycle lifetime is resolved with no cycle open or while one is closing. A resolution's two classes declare different lifetimes, or one class carries both PerProcess and PerCycle. A binding holding an instance declares Cycle or Transient, or disposeUsing() is declared on a binding that Cycle. |
DisposalException |
RuntimeException |
One or more disposals failed when a cycle closed. It carries every failure. |
DependencyResolutionException |
RuntimeException |
The class a Provider provides has no binding and cannot be constructed. |
Every exception will implement ContainerException.
Out of scope
- Opening and closing cycles. The HTTP worker, the queue worker and the scheduler do that, and each belongs to its own design.
- Disposing of
Processinstances when the worker shuts down, which belongs to bootstrapping. - Validating the dependency graph. A
Processobject taking aCycleinstance is not rejected.
Alternatives considered
The decisions this design rests on are recorded separately:
Naming the middle lifetime Request. We rejected this because HTTP opens a cycle for each
request, but a queue worker opens one for each job and the scheduler for each tick. Request would put HTTP
vocabulary into the container, and read wrongly for the other two.
Keeping the shared flag alongside the lifetime. We rejected this because two ways of saying the same thing would disagree.
Resolving a Cycle lifetime outside a cycle as Transient. We rejected this because it would produce an instance
with the wrong identity, and no error.
Nesting cycles. We rejected this because a second cycle opened inside the first would need a stack of cycle caches, and nothing needs one.
Partitioning InstanceCache by lifetime. We rejected this because a third cache keeps InstanceCache unchanged,
and closing a cycle discards one object instead of walking entries.
A forwarding proxy, built on the ghost object machinery, in place of a provider. We rejected this because it would resolve a different instance between two calls on the same object, with nothing visible where it is used.
Sharing a class with no binding, as RFC-0001 and
RFC-0006 specify. Every class the container constructs without a
binding would be Process. We rejected this because in a worker, a class that keeps state
and that nobody bound, such as one accumulating invoice lines, would carry that state into every later request and grow
until the worker restarts. It would be easy to do, and not every class can be expected to be bound.
Letting an implementation shorten its abstract's lifetime. A concrete class could declare a shorter
lifetime than its abstract, as a return type may narrow, but not a longer one. We rejected
this because a lifetime promises both sharing, which a longer one gives more of, and isolation, which a shorter one
gives more of, so neither direction keeps every promise. A Process abstract may be injected directly into a Process
object, and a Cycle implementation of it would be captured there from the first cycle, with
nothing visible where it is used.
The concrete class's lifetime winning. The concrete class's attribute would apply, and the abstract's only where there is no concrete class. We rejected this because whatever depends on the abstract could not rely on the lifetime it declares.
Leaving ghost objects outside lifetimes. A ghost object would stay its own instance,
created for each dependency and never cached, as in RFC-0001. We rejected
this because a ghost of a Cycle or Process lifetime would be a second instance beside the cached one, and a second
Cycle instance would never be disposed of, so writes to it, such as to a session, would be lost.
Ignoring the lifetime of a binding holding an instance. A Cycle or Transient lifetime declared on one would be
accepted and have no effect. We rejected this because a Cycle or Transient lifetime
cannot be honoured when every resolution returns the same instance, so declaring one is a mistake to report.
Accepting disposeUsing() wherever the resolved lifetime is Cycle. The check would be made at resolution, after
the class attributes are read. We rejected this because a disposal callback on a binding that does not declare Cycle
would be attached for nothing, and the mistake would surface only when the binding is first resolved. A class that
disposes of itself implements Disposable.
Making disposeUsing() declare the Cycle lifetime. Declaring a disposal callback on a binding would make it
Cycle. We rejected this because a lifetime set as a side effect of
another method is easy to miss when reading the binding.
No other alternatives were weighed.
Backwards compatibility
notShared()will be replaced bytransient(), and the shared flag on a binding, and thesharedargument ofBinding's constructor, byitsits lifetime.- A class with no binding will be
Transient, where RFC-0001sharesand RFC-0006 share it. - A binding holding an instance and declared
transient()will throw. TodaynotShared()on one has no effect, and every resolution returns its instance. - A ghost object of a
Processlifetime will be the shared instance, or be cached as it. Today every ghost is a separate instance, never cached.
Open questions
Changelog
- : Reworded in the readable house style, and the project's own vocabulary linked to the glossary. The design is unchanged.
- : Revised after review, against RFC-0006 as accepted. A lifetime attribute
is read from the requested class and the binding's concrete class, and a disagreement between them, or one class
carrying both attributes, throws. A binding holding an instance is always
Process.disposeUsing()requires the binding to declareCycleitself. OnlyCycleinstances are disposed of, in reverse order from a disposal list kept beside the cycle cache, so a dependency constructed later through a lazy proxy or a ghost is disposed of before whatever holds it. Ghost objects follow their lifetime. A liminal resolution that would beTransientisProcessbefore the cache is chosen, and a lazyCycleresolution with no cycle open throws when requested. RFC-0006 moves fromdependstoupdates. The alternatives for each, and the reason a class with no binding isTransient, are recorded.
Sources
- Issue #63, Engine - Container - Binding lifetimes, , edited on : the motivation, the three
lifetimes and why the middle one is not named
Request, the defaults,lifetime(),transient()and the removal of the shared flag,PerCycle, the third cache and its single selector, liminality with cycles not built, opening and closing cycles, throwing outside a cycle and for nested cycles, a process instance depending on a cycle instance, providers, disposal in reverse order with its contract left open, and the boundaries. Its edit of addedProvide,ProviderResolver, the check when the provider is built, and providers being for a lifetime mismatch only. A comment of the same day records why: the first consumer, in sessions, showed that a parameter typedProvidercarries nothing saying what to provide. - A class with no binding being
Transient, with no attribute for it; thePerProcessattribute; the order a lifetime is decided in; liminality applying toProcessalone, with a liminal resolution of aCyclelifetime stayingCycle; the provider check meaning a binding or a constructible class; lazy proxies and ghosts of cycle instances resolving once; disposal through bothDisposableanddisposeUsing(), with the binding's callback used instead ofdispose()and valid only onCyclebindings; the rules for a closing cycle; the cycle methods; and the exceptions: first written down on . - The disposal list and its order; reading a lifetime attribute from both classes and throwing
when they disagree; a binding holding an instance being
Process;disposeUsing()requiring a declaredCycle; disposal ofCycleinstances only; ghost objects following their lifetime; the reason a class with no binding isTransient; and the alternatives rejected for each: first written down on .
Source rfc/0007-binding-lifetimes.md · commit e429cda · published