On this page

Managed spell pools: lease, release, reset๏ƒ

Prerequisites: Choose an instance lifetime, Cleanup and memory ownership and Spell spaces, lineage, and child scopes.

Some objects cost more to build than to lend: connections, sessions, buffers, licence seats. Bind such a class with the managed_spell_quantity existence and Melder keeps its objects in a pool. A meld leases an object - an idle one when the pool has one, a new one when it does not - and the holder gives it back when it is done. Nothing goes back on its own: returning an object is the holder's decision.

Bind a managed spell๏ƒ

import melder as md

book = md.Spellbook()
book.bind(
    spell=DatabaseConnection,
    existence=md.Existence.managed_spell_quantity,
    managed_spell_mode="limited_quantity",      # elastic (default) | limited_quantity | queue
    managed_spell_limit=8,                      # at most 8 connections alive at once
    managed_spell_reset_methods=["rollback"],   # run on every release, in order
    disposal_method_names=["close"],            # run only when the pool destroys one
)
conduit = book.conjure()

The fluent form stages the same options:

spell_id = (
    md.SpellBinder(book)
    .bind(DatabaseConnection)
    .as_managed_spell_quantity("queue", limit=1, timeout_seconds=5.0, reset_methods=["rollback"])
    .finalize()
)

Each managed spell gets one pool in the store of the root conduit that owns it - at conjure, or at the bind itself when you bind on a live root. The pool starts empty and is registered with md.ReleaseManagedSpell(), the process-wide registry that objects go back through.

Option

Meaning

managed_spell_mode

"elastic" (default), "limited_quantity" or "queue"; a md.ManagedSpellMode member works too

managed_spell_limit

elastic: the most idle objects the pool keeps (default 256); limited_quantity and queue: the most objects alive at once, idle plus out (required)

managed_spell_timeout_seconds

queue only, and required there: how long a meld waits for a release

managed_spell_reset_methods

names of zero-argument methods run, in order, on every release

Bind refuses a malformed managed binding before anything is registered: a managed option on another existence, a function or other non-class target, an unknown mode, a limit below 1, a limited_quantity or queue binding without a limit, a timeout on an elastic binding, a queue binding without one, and reset names that are not a list or tuple of method names of the class. A managed spell may not depend on a shorter-lived unique_per_conduit spell: conjure refuses that as a scope-ordering violation.

Choose a pool mode๏ƒ

Mode

When every object is out

Typical use

elastic

Never refuses: a meld builds another object. A release that would keep more idle objects than the limit destroys the oldest idle one, and its disposal methods run.

Objects that must always be available; the limit caps what stays parked

limited_quantity

A meld raises ManagedSpellExhaustedError before anything is built

A hard cap, such as licence seats or a connection quota

queue

A meld waits up to managed_spell_timeout_seconds; a release hands the waiting meld the released object; past the timeout the meld raises ManagedSpellExhaustedError

A scarce resource where waiting beats failing

ManagedSpellExhaustedError is a MeldExecutionError. It carries mode, limit and timeout_seconds (None outside queue mode), and its message names the way out: release an object, or bind a higher limit - or, for a queue, a longer timeout.

try:
    conduit.meld("LicenseSeat")
except md.ManagedSpellExhaustedError as refused:
    print(refused.mode, refused.limit, refused.timeout_seconds)  # limited_quantity 2 None

Run Managed spell elastic pool, Managed spell limited quantity and Managed spell queue to see each mode at its limit.

Lease objects๏ƒ

  • conduit.meld("DatabaseConnection") leases one object. The pool hands out the most recently released idle object first and builds a new one only when none is idle.

  • Constructor injection leases too. Annotate a parameter with the managed class: every injected parameter is its own lease, so two consumers hold two objects.

  • conduit.managed_spell_meld(DatabaseConnection) melds the spell and returns a md.ManagedSpellLease whose with block gives the object back. It refuses a spell that is not managed with ValueError, before melding anything.

Give objects back๏ƒ

release = md.ReleaseManagedSpell().release

connection = conduit.meld("DatabaseConnection")
release(connection)                 # resets run, then the object is idle again

with conduit.managed_spell_meld(DatabaseConnection) as connection:
    ...                             # given back when the block ends, even if it raised

held = conduit.meld("DatabaseConnection")
with md.ReleaseManagedSpell().managed(held) as same:  # wrap an object you already hold
    ...

release(obj) finds the pool by the object's class. When more than one pool manages that class, pass spell_id= or conduit= to pick one. A class that no live root pools, a filter that no pool matches and a filter of the wrong type are refused. release_all(objects) gives several objects back at once.

A lease releases exactly once: a second release() does nothing, and lease.object raises RuntimeError once the object went back, because the pool may already have leased it to someone else. A with block never swallows the block's own exception. Run Managed spell leases for every lease form.

Reset objects between holders๏ƒ

The pool calls the named reset methods, in the order given, on every release and before the object is idle again, so the next holder receives a clean object.

A reset that raises means the object cannot be trusted. The pool destroys it - its disposal methods run and its place is freed - and the reset's error propagates from release. The next meld builds a fresh object. Run Managed spell reset methods to watch both outcomes.

Inspect a pool๏ƒ

(snapshot,) = md.ReleaseManagedSpell().describe(DatabaseConnection)
print(snapshot["count"], snapshot["idle_count"], snapshot["out_count"])  # alive, idle, out
print(snapshot["mode"], snapshot["limit"], snapshot["timeout_seconds"])

describe(cls) returns one snapshot per pool of the class, and an empty tuple when no live root pools it. Each snapshot also names the spell (spell_id, spell_name), the root that holds the pool (holding_conduit_id) and the reset and disposal method names.

Request scopes and root teardown๏ƒ

The pool belongs to the root conduit and outlives every lesser conduit made from it. A request-scoped handler can return its connection in its own disposal method: leaving the request scope disposes the handler, and the connection goes back to the root's pool for the next request.

class RequestHandler:
    def __init__(self, connection: PooledConnection) -> None:
        self.connection = connection

    def finish(self) -> None:
        md.ReleaseManagedSpell().release(self.connection)


book.bind(spell=RequestHandler, existence=md.Existence.many, disposal_method_names=["finish"])
root = book.conjure()

with root.enter_lesser_conduit() as request:
    handler = request.meld("RequestHandler")  # its connection is leased from the root's pool
# the scope ended: finish() ran and the connection is idle in the root's pool

When the root is torn down, its pools are cleaned first: idle objects are disposed, and an object that is still out stays its holder's to dispose. The pools are then unregistered, so a later ReleaseManagedSpell().release(obj) raises ValueError and a lease's release() raises RuntimeError.

Because the pools are cleaned before the root's other disposals run, a consumer melded on the root itself that releases its object in its own disposal method meets a pool that is already gone, and the root's teardown raises an ExceptionGroup. Release such objects before the root ends, or keep their holders in a lesser conduit as above. Managed spell injection and request scopes runs this pattern end to end.

A purge does not empty a pool: conduit.purge returns 0 for a managed spell, and a spell space refuses to purge one (see spell spaces and scopes).

Change settings or move a pool๏ƒ

A managed spell's mode, limit, queue timeout and reset methods are settings, not part of its spell_id. In a dynamic world, change them with cleanup_spell and a new bind: the spell_id stays the same and so does the pool, and every object alive stays alive and counted under the new settings.

spell_id = root.bind(spell=ExportWorker, existence=md.Existence.managed_spell_quantity,
                     managed_spell_mode="limited_quantity", managed_spell_limit=1)
root.cleanup_spell(spell=root.get_spell_by_spell_id(spell_id))
rebound = root.bind(spell=ExportWorker, existence=md.Existence.managed_spell_quantity,
                    managed_spell_mode="limited_quantity", managed_spell_limit=3)
assert rebound == spell_id  # the same spell and the same pool, now with limit 3

transfer_spell_ownership moves the spell to another root, and its pool goes with it, idle objects included. The new owner leases from it, releases still find it by class, and the old owner can no longer meld the spell. Read Named conduits, transfer, and sever before moving spells, then run Managed spell settings and transfer.

The workflow in code๏ƒ

These are the core steps from the saved example. Use its complete linked script for all class definitions and setup.

 1def main() -> None:
 2    """Lease, release, reuse and trim pooled objects, then tear the root down.
 3
 4    Contract:
 5        Every assertion checks an observable outcome: object identity, the pool's
 6        counts from describe(), or the disposal flag. The conduit and the book are
 7        cleaned in finally blocks.
 8    Returns:
 9        None.
10    """
11    book = md.Spellbook()
12    try:
13        book.bind(
14            spell=DatabaseConnection,
15            existence=md.Existence.managed_spell_quantity,
16            disposal_method_names=["close"],
17        )
18        book.bind(
19            spell=ScratchBuffer,
20            existence=md.Existence.managed_spell_quantity,
21            managed_spell_mode=md.ManagedSpellMode.elastic,
22            managed_spell_limit=2,
23            disposal_method_names=["close"],
24        )
25        conduit = book.conjure()
26        # Bind the release once: it is the one way back for every managed object.
27        release = md.ReleaseManagedSpell().release
28        try:
29            # The pool exists from conjure on, empty, with the default elastic policy.
30            (snapshot,) = md.ReleaseManagedSpell().describe(DatabaseConnection)
31            assert snapshot["mode"] == "elastic" and snapshot["limit"] == 256
32            assert pool_counts(DatabaseConnection) == (0, 0, 0)
33            assert not conduit.has_live_creation("DatabaseConnection")
34            print("pool ready at conjure:", snapshot["mode"], "limit", snapshot["limit"])
35
36            # Nothing is idle yet, so each meld builds a connection and leases it out.
37            first: DatabaseConnection = conduit.meld("DatabaseConnection")
38            second: DatabaseConnection = conduit.meld("DatabaseConnection")
39            assert first is not second
40            assert pool_counts(DatabaseConnection) == (2, 0, 2)
41            assert conduit.has_live_creation("DatabaseConnection")
42            print("two connections leased (alive, idle, out):", pool_counts(DatabaseConnection))
43
44            # Give one back: it becomes idle, still open, and the next meld reuses it.
45            first.queries += 1
46            release(first)
47            assert pool_counts(DatabaseConnection) == (2, 1, 1) and not first.closed
48            reused: DatabaseConnection = conduit.meld("DatabaseConnection")
49            assert reused is first and reused.queries == 1
50            print("the released connection came back on the next meld:", reused is first)
51
52            # The newest released object is leased first.
53            release(second)
54            release(reused)
55            newest: DatabaseConnection = conduit.meld("DatabaseConnection")
56            assert newest is reused
57            release(newest)
58            assert pool_counts(DatabaseConnection) == (2, 2, 0)
59            print("newest released leased first; all back (alive, idle, out):", pool_counts(DatabaseConnection))
60
61            # Elastic never refuses: with limit 2 a third buffer is still built...
62            buffers = [conduit.meld("ScratchBuffer") for _ in range(3)]
63            assert pool_counts(ScratchBuffer) == (3, 0, 3)
64            # ...but only 2 stay idle: the third release destroys the oldest idle buffer.
65            for buffer in buffers:
66                release(buffer)
67            assert [buffer.closed for buffer in buffers] == [True, False, False]
68            assert pool_counts(ScratchBuffer) == (2, 2, 0)
69            print("limit 2 keeps 2 idle buffers; the oldest was closed:", buffers[0].closed)
70        finally:
71            conduit.cleanup()
72
73        # Root teardown disposes what the pools hold and unregisters them.
74        assert first.closed and second.closed
75        assert md.ReleaseManagedSpell().describe(DatabaseConnection) == ()
76        print("root teardown closed the idle connections:", first.closed and second.closed)
77    finally:
78        book.cleanup()

Runnable examples๏ƒ

All intermediate examples ยท Level contents ยท Full contents

Canonical page source