Vancetope — Lease System (Resource Leases)
Authoritative product reference for the limited, fair sharing of scarce resources across all Tenants. Planning + design history:
planning/lease-system.md. Implementation documentation (AI perspective):readme/lease-system.md. See also: settings-system (Scope Cascade), server-tools (mcp_servertools,leaseparameter), mcp-tool-routing, damogran-system
1. What the System Is — and Is Not
A Lease is a slot on a logical resource (tex2pdf compilation,
MCP server browser session, Workspace Compose run). Concurrent usage is
limited by four quota levels that stack as a funnel:
| Level | Scope |
|---|---|
global |
across all Tenants — the resource ceiling |
tenant |
per Tenant — fairness on the resource |
project |
per Project — fairness within the Tenant |
user |
per User — e.g., exactly one active browser session |
An acquisition must obtain a free slot at every configured level
(check order narrow → wide: user → project → tenant → global);
levels without a cap are pass-through. cap: 1 results in an exclusive Lease.
What it is not:
- Not a deployment topology. The levels are quota scopes on logical resources. How often a resource is physically deployed is irrelevant — k8s manages the Node, the Lease system manages admission.
- Not access control. The Lease is a voluntary agreement among cooperative consumers. Anyone allowed to write the consumer config can disable or bypass the Lease — this is accepted. Protection against misuse is provided by the resource itself (Rate-Limit, 429) and k8s.
Multi-Pod: The Brain is multi-pod capable; all Lease state resides in
MongoDB (resource_lease_counters, resource_lease_holders).
In-memory locks cover a single Pod and are insufficient here.
2. Lease Definitions — Config Documents
Nothing about the Lease is hardcoded in the consumer. A
Lease definition is a YAML document at the path
_vance/config/lease/<name>.yaml:
name: tex # must match the filename
resource: tex2pdf # Counter key (Default: name)
levels:
global: 6
tenant: 3
project: 1
user: 1 # all four optional; none = NOOP-Lease
ttlSeconds: 120
waitMs: 10000
Four layers, resolved field-by-field (not first-document-wins):
- classpath —
vance-defaults/_vance/config/lease/<name>.yaml(Add-on defaults, e.g., tex add-on) - System — Tenant
_vance, Project_tenant(Operator overrule) - Tenant — Holder’s Tenant, Project
_tenant - Project — Holder’s Tenant + Project
Field ownership (most specific allowed layer wins):
| Field | Allowed Layers |
|---|---|
levels.global |
classpath → System — a Tenant cannot raise the global ceiling; entries from deeper layers are ignored |
resource |
classpath → System → Tenant — a Project does not unintentionally land on its own counter |
levels.tenant |
classpath → System → Tenant |
levels.project, levels.user, ttlSeconds, waitMs |
all four |
The merged definition is cached per (tenant, project, name) and
invalidated via the Document Change Event chain (local and cross-pod;
System layer writes drop the name everywhere).
Fail-closed on broken config: A layer that is unreadable
(store error) or fails to parse (broken YAML, non-numeric value,
Cap < 1) causes the load to fail with LEASE_UNAVAILABLE — and nothing
is cached. A silently skipped layer might carry the exact Cap that
protects the resource. Caps are ≥ 1 (cap: 0 would not block, but create
the counter on the first Acquire); to disable a resource, specify this
on the consumer (tex.lease: none). Lease names are path segments and
follow [A-Za-z0-9][A-Za-z0-9_-]{0,63}.
Consumer referencing — two forms:
- Name reference — the consumer config only specifies the name:
tex: Setting
tex.lease(none/empty = no Lease, Defaulttex);mcp_servertool config: parameterlease: browser. Same name = shared funnel. - Inline definition — MCP configs may carry a Lease config inline
(
lease: { name: …, levels: { … } }); inline means: no layers, no merge.nameis mandatory. Because the tool config is usually project-writable, the same field ownership applies to inline as to the Project layer: onlylevels.project,levels.user,ttlSeconds,waitMs.resource,levels.global, andlevels.tenantare rejected, and the counter resides on a project-private resource (mcp-inline:<tenant>/<project>/<name>) — otherwise, a Project withresource: tex2pdfand a highglobalceiling could occupy the shared funnel of all Tenants. Shared ceilings belong in a name reference.
3. Consumer Contract
Lease lease = resourceLeaseService.acquire(LeaseRequest.builder()
.type("tex") // Handler type, set by consumer code
.lease("tex") // Name reference (or .inline(def))
.tenantId(t).projectId(p).userId(u)
.holderRef(processId) // consumer-specific
.pod(clusterService.selfPodId())
.podBound(true) // Resource dies with the Pod?
.build()); // LeaseBusyException / LeaseUnavailableException
lease.updatePayload(Map.of("jobId", jobId)); // often only known after acquire
lease.check(); // EVERY usage step
lease.release(); // try-with / idempotent
acquirewaits up towaitMs(Poll with Jitter — waiting Pods must not race the counter in lockstep), then a typed errorLEASE_BUSYwith diagnosis (resource, level, occupancy). Never block indefinitely — the call runs on a Lane. Fail-closed: If Mongo is unreachable →LEASE_UNAVAILABLE, the resource is not used unprotected. A type without a registered handler is rejected (the Reaper must be able to dispatch later).check()— Check-at-Use: call at every usage step. ThrowsLEASE_LOSTif the Lease was lost (Idle-Reap/Pod-Reap), and simultaneously acts as a Touch:lastSeenAt = now— the TTL clock runs from the last usage, not from acquisition. No background heartbeat timers.- TTL invariant per consumer: TTL > longest interval between
two
check()calls. A poll loop keeps its Lease alive through its own polling. - NOOP path: no Lease referenced or no Caps configured →
acquirereturnsLease.NOOPwithout any Mongo traffic.
4. Storage Model (MongoDB)
resource_lease_counters — one Doc per (resource, level, scope),
_id = <resource>|<level>|<scope>; the User scope is tenant/user
(cross-project — “one browser session per User” does not multiply with
the number of Projects). Acquisition is an atomic
conditional update without upsert: if the filter count < cap matches,
it increments; if it does not match, the level is full — unless the Doc
does not yet exist. In that case, a pure _id upsert creates it with
count: 0, and the conditional update runs a second time. An upsert
within the conditional update would be incorrect: with simultaneous
first use, the loser’s insert would collide with the _id, and Mongo
only retries such upserts with a pure _id equality filter — the loser
would falsely report Busy.
resource_lease_holders — one Doc per held Lease (Holder first:
PENDING → per level Counter-Inc then $push of the key into levels
→ HELD). An incremented Counter never exists without a Holder Doc that
knows about it. lastSeenAt/TTL run on Mongo server time
(write $currentDate — also on creation, the Holder is created via upsert
— $$NOW in the Reap filter) — Reaper and Holder are on different Pods.
No Mongo TTL index: deletion must decrement the Counters. A Touch counts
as “still held” if it matches the Doc, not if it changes it: two
touches in the same millisecond write the same value, Mongo reports
nModified = 0.
Rollback: If an attempt fails — level full or store error mid-funnel — it deletes its Holder Doc and decrements the already taken levels; a half-booked Holder would otherwise block a Cap-1 slot for an entire TTL. Only the one who deletes the Holder Doc decrements: if a Reaper has already taken it, it has decremented the registered levels, the rest is left to reconcile.
Release = findOneAndDelete on the Holder Doc, then decrement of
the registered levels — only the one who deletes the Doc decrements;
Release and Reaper are thus idempotent.
Reconcile: a crash window remains between Counter-Inc and Level-Push
(and between Delete and decrement). The periodic sweep recalculates
the Counters from the Holder-levels and corrects discrepancies —
only Counters whose updatedAt is older than a safety margin
(vance.lease.reconcile-safety-ms, Default 60 s), and only if
count/updatedAt have remained unchanged since reading. Counters that
no Holder books anymore are deleted by reconcile instead of being zeroed
— otherwise, a dormant Doc would remain forever per User/Project; the next
Acquisition creates it anew.
5. Reap — Two Reasons
| Reason | Trigger | Effect |
|---|---|---|
| Idle | lastSeenAt + ttlSeconds < $$NOW |
Delete Holder Doc, decrement Counter, Handler Dispatch (§6) |
| Pod dead | Holder Pod dead according to ClusterService criterion (same as Project ownership: not stopped, not stale) |
Immediately release all Leases of the Pod; TTL is now only responsible for Idle |
Reap runs as a lazy Sweep on acquire (only Idle, only the affected
resource) plus a @Scheduled sweep (vance.lease.reap-interval, Default
30 s; Idle + Pod dead + Reconcile). Due to delete idempotence, it does
not matter how many Pods reap simultaneously. Both paths dispatch the
Handlers (§6): the lazy Sweep passes its Holders via the
LeaseRevocationListener-SPI (shared) to the LeaseRevocationDispatcher
(brain), which sends them asynchronously through the same matrix — the
acquiring thread waits neither for Handlers nor for a Pod-POST.
The lazy Reap is often the common case (another Pod wants the slot);
without dispatch, the resource would continue to run.
6. Lease Types and Handlers
A released Lease does not notify the consumer itself — a Reap that
only decrements the slot would let the resource continue to run
(Chromium in container, Compile-Poll) and the Counter would count claims
instead of instances. Therefore, every Lease type has a LeaseHandler
(Spring Bean; interface in vance-shared, registry in vance-brain):
- Type and
podBoundare set by the consumer code (LeaseRequest), not the config — only the code knows what it needs to clean up.podBound=truemeans: the resource lives in the Holder Pod and dies with it. payloadin the Holder Doc carries what the Handler needs for cleanup (mcp: Endpoint +Mcp-Session-Id; tex: Job-Id) — updatable afterwards, because values are often only known after acquisition.- A regular
release()does not call the Handler — the Holder cleans up its resource itself.onRevokedonly runs on revocation.
Dispatch Matrix (podBound is written to the Holder Doc on Acquire,
so the Reaper can decide without the Handler):
| Case | Behavior |
|---|---|
| Holder Pod alive (this Pod) | Handler local |
| Holder Pod alive (foreign) | internal POST /internal/lease/revoked (Document Change Events pattern); POST failure counts as “unreachable” → path below |
Holder Pod dead + podBound |
only release — the resource died with the Pod |
Holder Pod dead + not podBound |
Handler runs on the Reaper Pod with the Payload (e.g., DELETE of the Remote Session) |
“Pod alive” has exactly one definition (LeasePodLiveness), shared
by Sweepers (Pod-dead-Reap), Dispatchers, and the operational view (§9):
the Project ownership criterion (ClusterService.resolveEndpointByPodId —
not stopped, not stale, Endpoint known), and the own Pod is always alive
— it is obviously performing the check. A (still) missing or delayed
own heartbeat entry neither makes it a Reap target for its own Leases
nor causes the local Handler of a podBound resource, which is still
running here, to fail.
A lost notification is additionally secured by check(): the Holder
receives LEASE_LOST on the next step.
Module cut: Storage, Counter, Idle-Reap, and Reconcile in
vance-shared (ResourceLeaseService — only needs Mongo);
Pod-dead detection, Handler Registry, Dispatcher, forwarding, and
internal Controller in vance-brain (ClusterService and the
Cross-Pod-Controller live there; shared has no web endpoints). The
seam is LeaseRevocationListener: shared only knows the interface, brain
provides the implementation; without it (anus), a Reap is only logged.
7. Consumers
tex2pdf (vance-addon-brain-tex) — Type tex, Setting tex.lease,
bundled default definition
(vance-defaults/_vance/config/lease/tex.yaml: global 6, tenant 3,
project 1). acquire before submit, updatePayload(jobId) afterwards,
check() in each poll iteration, release on result. Overload
appears as LEASE_BUSY instead of remote 429. Handler tex is
podBound (the poll state lives in the Pod): onRevoked marks the
running poll loop as aborted (TexCompileTracker: the loop
registers and unregisters, a revocation after loop end leaves nothing);
the remote job itself cannot be aborted (no cancel endpoint) and
expires server-side. The Compile carries the User of the Damogran run,
so levels.user applies; a store error during check() ends as an
error result like any other compile error.
Browser Session (Brain-MCP-Client) — Type mcp, parameter lease in
the mcp_server tool config. The Lease is tied to the Session lifecycle
in McpConnection (not the pool — ensureInitialized re-opens after
close() silently): acquire on Session-Open, Payload
(Endpoint + Session-Id) after initialize, check on every
tools/call, release on Close. The bridge is the narrow
SessionGuard-interface in vance-toolpack (no Spring, no Mongo) —
the Brain implements it lease-backend, Foot gets none.
podBound follows the transport (stdio: subprocess dies with the Pod;
HTTP: session lives in the foreign server). A failure after acquire
(transport open, handshake, payload update) immediately returns the Lease.
LEASE_LOST on tools/call closes the Session
(McpSessionRevokedException via the SessionGuard-interface); the
next call opens a new one and acquires anew — otherwise, the connection
would fail on every call until pool invalidation.
Handler mcp, two cases:
- The Session is in this Pod’s pool and still holds the revoked Lease (token comparison) → close pool connection. A delayed revocation for a session that has since been re-opened closes nothing.
- Holder Pod dead, not
podBound(HTTP) → the Reaper Pod loads the Tool Config via the Cascade (Payload: Tool Name, Endpoint, Session-Id) and sends the Session-DELETE with the configured Auth. If the tool now points to a different endpoint, the DELETE is omitted.
Workspace Compose Run — v2 candidate: exclusive Lease (Cap 1) per
(tenant, project, workspace-name) for the entire run; withLabelLock
currently only secures provisioning, the subsequent run proceeds
unprotected in the shared directory.
Prerequisite (included, independent of Lease):
McpHttpTransport.close() sends the Session Termination DELETE with the
Mcp-Session-Id-header and the same Auth as the Session’s requests —
previously, the server-side session remained unused on every connection
teardown (pool invalidate, OAuth disconnect, shutdown), and an anonymous
DELETE would fail on any authenticated server.
8. Metrics
vance.lease.acquire{resource,outcome=success|busy|noop|unavailable},
vance.lease.check{resource,outcome=ok|lost},
vance.lease.release{resource,outcome=ok|already-released|error},
vance.lease.reap{reason=idle|pod|revoke}, vance.lease.reconcile{outcome=corrected}.
Tags low-cardinality; no Tenant-/User-Ids as tags.
9. Operations — View and Revocation
Two interfaces, separated by scope:
| Interface | Scope | Read | Act |
|---|---|---|---|
| Insights → Leases (Web-UI) | own Tenant, Tenant/ADMIN |
Holders of the Tenant; Counters of tenant/project/user levels |
Revoke a Holder of the Tenant |
anus lease … |
all Tenants | Holders, Counters including global, merged definition |
Revoke a Holder or all Holders of a Pod, Reaper round, Reconcile |
The global level belongs to no Tenant. Its occupancy sums all
Tenants and therefore only appears in anus. A revocation via Insights
only affects a Holder of the path Tenant — tokens are global, Tenant
binding is in the delete filter, not in the client.
Revocation is a Reap based on Operator decision, not a release:
Holder document removed, booked levels returned, then the Handler of the
type runs via the same LeaseRevocationDispatcher as during the Sweep
(§5, podBound matrix). Only releasing the slot would let the resource
continue to run. The Holder itself notices it on the next check()
(LEASE_LOST). Metric: vance.lease.reap{reason=revoke}.
anus reads directly from Mongo, revokes via the Brain
(/internal/lease/admin/…, Shared-Secret): only the Brain has
Handler Registry and Pod Routing. --force revokes directly in Mongo
without the Brain — in case none is reachable. In this case, no
Handler runs: the slot is free, the Holder fails on the next check(),
a resource in a foreign system (Remote-MCP-Session of a dead Pod) is
left to its own expiration. The output states this.
Commands: lease list (Filter Tenant/Resource/Pod/Type), lease show,
lease counters (with BOOKED = number of Holders booking the Counter,
and drift marker), lease definition (merged definition as an
Acquisition would see it in scope — cache discarded beforehand, anus is
not tied to the change event chain), lease revoke, lease revoke-pod,
lease sweep (a Reaper round now, with Handlers), lease reconcile
(Counter drift, needs no Brain and no Handler).
Make drift visible instead of hiding it: Both interfaces show, besides the Counter status, the number of Holders that book it — the value to which Reconcile corrects. A difference is drift from a crash window, not a display error.