Licenses and Limits
How OpenCue counts, reports and minimizes third-party license usage
Overview
A limit is a named cap that a layer can be bound to. Historically OpenCue counted one token per running frame, which is the right model for a resource consumed by a process — but the wrong model for the licenses most studios actually care about.
This guide covers the extension of that system into real license management:
- A limit declares how it counts: per frame (
FRAME) or per host (HOST). - An external reporter can feed Cuebot the license server’s own view, including which hosts hold a token, so licenses drawn outside the farm are accounted for.
- The dispatcher actively minimizes the number of machines the farm spreads a license across, rather than merely staying under a cap.
- Layers that need a license are discovered from frame failures, not only from job-spec tags.
- A limit declares whether it is enforced or advisory.
Everything is additive. An existing limit migrates to FRAME + ENFORCED with no reporter, and
behaves exactly as it did before. The one exception is legacy rows with b_host_limit = true,
which migrate to HOST.
Cuebot never talks to a license server and never checks a license out. It observes and biases. Checkout stays where it belongs: in the DCC, at frame start. The license server remains the authority.
1. Why frame counting is wrong for licenses
Two problems, both structural.
Licensing is usually per host, not per frame. Houdini/sesinetd, Katana, Mari and Clarisse all
issue per-machine licenses: any number of processes on one machine draw a single token. A limit of
50 against a 50-seat Houdini pool stalls the farm at roughly 50 frames while the license server
still has 40+ free seats.
Cuebot is not the only consumer. Artists on workstations draw from the same pool. Any purely internal count is wrong the moment a human opens Houdini — and this is the case that matters most, because every license the farm takes is one an artist cannot have.
The economics: under-counting is cheap, over-counting is expensive
If Cuebot books a frame that turns out to have no license, the frame reports a license-shortage exit status, the layer’s booking is postponed for a few minutes, and the frame retries without consuming a retry or dying. The farm recovers on its own.
If Cuebot withholds a booking because its accounting is playing safe, nothing recovers it. The frame waits, the host idles, and the license sits unused on the server. There is no retry mechanism for a booking that never happened.
Every judgement call in the counting model below resolves toward under-counting.
2. The counting model
2.1 Settlement, not max()
The two sources of truth — Cuebot’s own bookings and the license server’s report — are not two opinions about the same quantity. They cover disjoint time ranges.
A Houdini frame typically checks a license out for hip load and scene build, then releases it well
before the frame finishes. Counting every running frame as a held license means a host running six
frames that collectively hold zero licenses is counted as holding six. Taking max(internal,
external) would guarantee that phantom reservation can never be corrected downward — the server’s
smaller, true number always loses.
Instead, let W be limit_record.ts_reported, the moment the license server’s view was captured:
settled(h) = tokens the server reported h holding as of W.
Ground truth. Already reflects releases, artists, everything.
pending(h) = frames on h, in layers bound to the limit, dispatched after
(W - settle_window). Bookings the server has not yet had a
chance to observe.
tokens(h) = settled(h) + pending(h) -- additive: disjoint in time
usage(h) = 1 if type = HOST and tokens(h) > 0
= tokens(h) if type = FRAME
usage(L) = Σ usage(h) over every host appearing in either set
Walking the cases:
| Situation | settled | pending | counted | Right? |
|---|---|---|---|---|
| Frame booked 10m ago, still holds a license | 1 | 0 | 1 | ✓ |
| Frame booked 10m ago, released its license | 0 | 0 | 0 | ✓ — the whole point |
| Frame booked 5s ago, server hasn’t polled | 0 | 1 | 1 | ✓ no stampede |
| Artist on a workstation | 1 | 0 | 1 | ✓ |
| Artist logged into a busy render host | 1 | 0 | 1 | ✓ not double counted |
| 6 frames on one host, 1 license held (HOST) | 1 | 0 | 1 | ✓ |
The second row is the entire justification for the model. Under max() it counted 1 (HOST) or 6
(FRAME) forever; under settlement it drops to zero the moment the server says so.
A host that appears in both sets is counted once: the pending scan excludes hosts the snapshot already covers, so reaching back cannot double count.
2.2 The settle window
pending is scanned from ts_reported - limit.settle_window_seconds, not from ts_reported
itself. The window reaches backwards from the watermark, because a checkout takes time to appear
on the license server — a frame dispatched a moment before a snapshot may be visible to neither
side.
limit.settle_window_seconds is a global property, default 120. Set it to roughly twice the
reporter’s poll interval.
A reporter that stops therefore grows pending toward “every running proc of a bound layer”, which
is the fail-closed direction, and it is already bounded: past int_report_ttl the limit stops
blocking altogether (below).
2.3 Never-reported limits
A limit with ts_reported IS NULL has no ground truth, so settled is empty and pending covers
every running proc of a bound layer, unbounded by the settle window. That is exactly today’s
counting, which is what makes the migration a no-op for existing limits.
2.4 Staleness
A limit is stale when now() - ts_reported > int_report_ttl (default 900 seconds; 0
disables staleness for an internal-only limit).
A stale limit stops enforcing and behaves as advisory, flagged in the API, the GUI and the metrics. This follows directly from the economics: the license server is still enforcing for real, so gating the farm on data we know is wrong buys nothing and costs idle time. Frames that lose the race fail with the license-shortage status and ride the backoff path.
There is deliberately no stale_policy knob. A site that wants a stale snapshot to keep gating for
longer raises report_ttl.
2.5 The asymmetry principle
Two different questions get two deliberately different answers, and both biases push toward using fewer licenses:
- “How much is in use?” → optimistic. Settled + pending only, so released licenses stop counting and the farm keeps booking.
- “Does this host already hold one?” → generous. Any running frame of a bound layer, or any reported hold, counts as holding. A host that might still have the license is treated as though it does, so work packs onto it rather than lighting up a new machine.
3. Minimizing the license footprint
A cap is not the goal. The pool is shared with artists, so every host the farm lights up is a license a person cannot have. Three mechanisms concentrate licensed work onto as few machines as possible, well before any threshold is in sight.
3.1 max_value means machines, for a HOST limit
For a HOST limit, int_max_value is not “maximum licenses” — it is the maximum number of
distinct machines the farm may spread this license across. A studio with a 50-seat Houdini pool that
wants 20 seats kept free for artists sets the limit to 30, and the farm packs into 30 machines.
This is the primary footprint control, and the GUI states it in exactly these words, because “max value” invites the wrong mental model.
3.2 Proc reuse (pre-existing)
FrameCompleteHandler.handlePostFrameCompleteOperations already keeps a proc on the same job after
a frame completes. On the steady-state path host affinity is therefore free — a host that acquires a
Houdini license keeps feeding itself Houdini frames. The leak is in initial booking and cross-layer
moves, which is what the next two mechanisms cover.
3.3 Layer ordering bias
OpenCue dispatch is host-driven: RQD reports in and Cuebot asks “what should this host run?” We cannot pick a host for a frame, but we can pick the right work for a host. When a host reports in and already holds tokens for some limits, layers bound to those limits are offered first.
DispatchQuery.AFFINITY_ORDER_SQL prepends a sort key to the existing
frame.int_dispatch_order, frame.int_layer_order:
Work this host can do without acquiring a new license comes first.
Layers with no license limits and layers whose licenses this host already holds are indistinguishable at position 0, so unlicensed work is never penalized. Two guards:
- The bias applies within a job only. Job and priority selection are untouched.
- It is gated behind
dispatcher.limit.affinity_ordering_enabled(defaultfalse): the sort key is evaluated per candidate frame, so every job pays a small dispatch cost whether or not it uses license limits, and it sits ahead ofint_dispatch_order, reordering frames a user may be watching. Sites using HOST limits should turn it on to get the packing behavior described here.
The ordering bias applies to ADVISORY limits too — it never blocks anything, it only chooses among work already eligible. That composition is the point of advisory mode: a site can run every license limit advisory, never stall a frame, and still get the packing benefit.
3.4 The soft spread threshold
Ordering is a preference and can be overridden by circumstance. The hard control is a second, lower
threshold above which the farm stops lighting up new machines: limit_record.int_soft_value,
default -1 meaning “same as int_max_value”.
type = FRAME: usage < max_value
type = HOST: usage < soft_value
OR host_holds(limit, host)
- Below
soft_value— any host may take the work. The farm is allowed to grow. - At or above
soft_value— only hosts that already hold a token. The farm packs instead of spreading.
A host that already holds a token may always book, even at max_value: all frames on that machine
share the token, so the booking consumes nothing new. This is the core new behavior of a HOST
limit, and it is why max_value reads as “machines”.
Setting soft_value = max_value (the default) gives the plain rule. Setting soft_value = 0 gives
“never light up a new machine for this license” — an emergency lever when the report shows artists
are starved.
host_holds is the generous test from §2.5: any row in limit_host for that hostname, or any
running proc on that host in a layer bound to the limit.
no new license needed, first"] B --> C{"Layer bound to
an active limit?"} C -- no --> OK["Book"] C -- yes --> D{"Enforcement
= ENFORCED?"} D -- "ADVISORY / DISABLED / stale" --> OK D -- yes --> E{"usage < soft_value?"} E -- yes --> OK E -- no --> F{"Host already
holds a token?"} F -- yes --> OK F -- no --> SKIP["Skip layer"]
Local dispatch caveat. The local (workstation) dispatch queries gate on
max_valuealone, so a workstation already holding a token can be refused work the farm dispatcher would allow. This is a deliberate simplification — those queries have nohosttable in scope — not a structural limit. Revisit if artists hit it on saturatedHOSTlimits.
4. Enforcement modes
limit_record.str_enforcement ∈ {ENFORCED, ADVISORY, DISABLED}.
| Mode | Blocks booking | Affinity bias | Soft threshold | Usage shown |
|---|---|---|---|---|
ENFORCED |
yes | yes | yes | yes |
ADVISORY |
no | yes | no | yes |
DISABLED |
no | no | no | yes |
ADVISORY is the mode this feature was really missing. The license server already enforces —
authoritatively, and for artists as well as the farm. A site can therefore let Cuebot never block a
frame, let shortages surface as license-shortage exit statuses handled by the layer backoff, and
still get the whole footprint benefit, because the ordering bias is independent of the gate. That is
the recommended configuration for a farm whose licensing is genuinely enforced upstream.
DISABLED exists so an operator can neutralize a misbehaving limit without deleting it. Deleting a
limit_record throws away its layer_limit bindings, which nobody can reconstruct from memory at
2am.
The schema default is ENFORCED, so every existing limit keeps today’s semantics exactly. A stale
limit is treated as ADVISORY regardless of its configured mode.
5. Learning which layers need a license
Everything above assumes layer_limit is populated — that submitters declare
<limits>houdini</limits> in the job spec. In practice that coverage is poor, and a limit only
constrains what is tagged. An untagged Houdini layer consumes real licenses while contributing
nothing to pending, so the farm’s own usage is under-reported and the whole model degrades.
Waiting for every submitter to tag correctly is not a plan. The failures themselves are the signal: a frame that exits with the license-shortage status has proved it needs that license.
5.1 The failure rule lives on the limit
Three fields on limit_record, set together through SetFailureRule:
| Field | Meaning |
|---|---|
int_exit_status |
The frame exit status meaning “this license was unavailable”. NULL = no rule. |
int_delay_minutes |
How long to postpone the layer when a frame reports it. 0 = don’t delay. |
b_auto_tag |
Whether to bind the failing frame’s layer to this limit. Default true. |
They are three fields and not one because the combinations are all meaningful:
delay = 5, auto_tag = true— the normal license configuration.delay = 0, auto_tag = true— pure discovery. Learn coverage without changing dispatch at all.delay = 5, auto_tag = false— a status that isn’t really a license.
Because the rules live in the database rather than a property file, they are live-editable: a
change takes effect on the next LimitRuleCache refresh instead of a Cuebot restart.
The status must agree with what RQD is configured to emit via rqd.yaml
runner.log_exit_status_rules. 330 is the conventional license-shortage code.
5.2 Auto-tagging
FrameCompleteHandler.applyLimitRule runs on the frame-complete path:
- Skip if the frame was
EATEN— auto-eat wins, and nothing retries an eaten frame. - Look up the limit claiming this exit status in
LimitRuleCache. - If
auto_tag, bind the layer with sourceAUTO.LayerDao.addLimitis idempotent and returns whether a row was actually inserted, so a burst of failing frames logs and counts once, not five hundred times. - Write the layer backoff via the existing conditional-monotonic
ts_start_afterupdate.
Tag first, delay second. If anything throws, keeping the discovery and losing the backoff is the better trade. Discovery is best-effort throughout: it must never fail a frame-complete.
The delay applies whether or not the layer was already bound — the first failure is precisely the case where the layer is not yet tagged.
5.3 Binding provenance
layer_limit.str_source ∈ {SPEC, AUTO, MANUAL}, plus ts_created:
SPEC— declared in the job spec at launch. Never touched by automation.AUTO— inferred from a frame failure.MANUAL— added by an operator through the API or GUI.
One small column buys three things: the GUI marks inferred bindings, a mis-set exit status is reversible with a single provenance-scoped delete, and discovery rate becomes measurable.
5.4 Rails against a mis-set exit status
Claiming a generic status would bind most of the farm to one limit. Four guards:
- Statuses 0 and 1 cannot be claimed. 0 is success and is repurposed as the clear value; 1 is
the conventional catch-all failure and is rejected at the API and by a
CHECKconstraint, because claiming it would tag nearly every failing layer on the farm. - One limit per status, enforced by a partial unique index.
SetFailureRulereturnsALREADY_EXISTSnaming the other limit. - Bulk undo —
ClearBindings/ Remove Auto-Tagged Layers… in the GUI. One statement, provenance-scoped,SPECbindings never touched. - Visibility — every first-time binding logs at INFO and increments
cuebot_limit_auto_tag_total{limit}.
Clearing the rule (exit_status = 0) leaves existing AUTO bindings in place. Turning a rule off is
not the same as declaring everything it learned to be wrong; removing them is a separate, explicit
call.
5.5 Why discovery pairs with ADVISORY
As coverage grows, more running frames count toward the limit, so measured usage rises even though
actual license consumption has not changed. On an ENFORCED limit that looks like sudden
saturation and the farm stops booking — punishing the site for improving its own data.
So the sequence is:
- Create the limit
ADVISORY, withauto_tag = trueanddelay_minutesset. Nothing is gated. The affinity bias already applies, so the farm starts packing immediately. - Watch
cuebot_limit_auto_tag_totaland the tagged-layer count plateau. That is coverage converging. - Compare
settled_usageagainst the license server’s own numbers. When they track, the model is working. - Only then consider
ENFORCED— and quite possibly decide it adds nothing, since the license server enforces anyway.
Each step is observable and reversible, and a site can stop at step 3 permanently.
5.6 Known limitation: coverage does not survive job launch
layer_limit is per layer, and layers belong to jobs. Every relaunch of the same scene creates new
layers that start untagged and must fail once to be rediscovered — costing one frame failure plus one
delay_minutes stall per new job.
Mitigations, in order of effort:
- Keep
delay_minuteslow (1–2) for license limits. The backoff exists to stop a hot loop, not to be a penalty box, and the affinity bias means retries land preferentially on hosts that already hold the license. - Promote discoveries to the service.
layer.str_servicesalready carrieshoudini, and theservicetable is where per-service defaults live. Aservice_limittable applied at launch would make coverage durable across jobs. This is the natural next extension and is deliberately out of scope: it changes what a job gets at launch, which deserves its own design and its own opt-in. - Surface the pattern for a human. The bindings dialog groups recent
AUTObindings by service, so a promotion candidate becomes obvious without anyone running a query.
6. Database schema
Migration: V49__Add_limit_types_and_host_holds.sql.
limit_record
str_type VARCHAR(16) DEFAULT 'FRAME' NOT NULL -- FRAME | HOST
str_enforcement VARCHAR(16) DEFAULT 'ENFORCED' NOT NULL -- ENFORCED | ADVISORY | DISABLED
int_soft_value INT DEFAULT -1 NOT NULL
ts_reported TIMESTAMP(6) WITH TIME ZONE -- the settlement watermark
str_report_source VARCHAR(255)
int_report_ttl INT DEFAULT 900 NOT NULL
int_exit_status INT -- CHECK (IS NULL OR > 1)
int_delay_minutes INT DEFAULT 0 NOT NULL -- CHECK (>= 0)
b_auto_tag BOOLEAN DEFAULT true NOT NULL
The dead b_host_limit flag from V2__Add_limit_table.sql — never read by any code in any language
— is migrated to str_type = 'HOST'. The column itself is kept, not dropped: the migration is
purely additive so an older Cuebot can still run against a migrated database. str_type is
authoritative from here on, and nothing keeps b_host_limit in sync.
The migration also adds the primary key and the name uniqueness constraint that limit_record never
had, plus a partial unique index on int_exit_status.
Deployment gate. The name uniqueness constraint fails loudly on a database that already has duplicate limit names. Precheck before upgrading:
SELECT str_name, COUNT(*) FROM limit_record GROUP BY str_name HAVING COUNT(*) > 1;Resolve duplicates (rename or delete, repointing
layer_limit) first.
layer_limit
str_source VARCHAR(16) DEFAULT 'SPEC' NOT NULL -- SPEC | AUTO | MANUAL
ts_created TIMESTAMP(6) WITH TIME ZONE DEFAULT current_timestamp NOT NULL
plus a primary key and UNIQUE (pk_layer, pk_limit_record).
The unique constraint is load-bearing, not tidying. layer_limit had no uniqueness and
LayerDaoJdbc.addLimit was an unguarded INSERT. Every aggregate joining layer_limit counts a
proc once per matching row, so a duplicate silently doubles that layer’s usage contribution.
Auto-tagging calls addLimit on every failing frame, which would turn a latent bug into a live one
within minutes. The migration collapses pre-existing duplicates automatically — unlike a name
collision, those rows are provably redundant.
limit_host
The license server’s view of who holds a token.
CREATE TABLE limit_host (
pk_limit_host VARCHAR(36) NOT NULL,
pk_limit_record VARCHAR(36) NOT NULL,
str_host_name VARCHAR(256) NOT NULL, -- normalized: short name, lowercased
str_reported_name VARCHAR(256) NOT NULL, -- as the server reported it, for display
int_tokens INT DEFAULT 1 NOT NULL, -- CHECK (> 0)
str_user VARCHAR(64),
ts_reported TIMESTAMP(6) WITH TIME ZONE DEFAULT current_timestamp NOT NULL,
...
);
Keyed on hostname, not pk_host: most holders are artist workstations with no host row, and a
decommissioned-then-re-registered render host gets a new pk_host while keeping its name. pk_host
is resolved at read time by joining host.str_name, so a workstation that later joins the farm
starts matching automatically.
The table is set to fillfactor = 70 with aggressive autovacuum: reports rewrite a handful of rows
every few seconds, so the updates should stay HOT and the table should never bloat.
limit_usage
A precomputed summary — settled totals, settled host counts, watermark — so the dispatch gate reads
one row per limit instead of aggregating the farm on every dispatch query. Refreshed by the
LOCK_LIMIT_USAGE_RECALCULATION maintenance task every limit.usage_refresh_seconds, and
synchronously by the report path for the limits a report touches.
7. Configuration
opencue.properties:
# How long a license checkout may take to appear on the license server. The pending scan reaches
# back this far before a limit's report watermark. Set to roughly twice the reporter's poll
# interval. Default = 120.
limit.settle_window_seconds=120
# How often the limit_usage summary table is recomputed, in seconds. The report path also
# refreshes its own limit synchronously. Default = 5.
limit.usage_refresh_seconds=5
# Minimum seconds between accepted reports for the same limit. A faster report leaves that limit
# untouched and names it in the response's skipped_limits; the rest of the batch still applies.
# Default = 5.
limit.min_report_interval_seconds=5
# Whether dispatch orders layers a host can run without acquiring a new license ahead of layers
# that would light up a new one. Off by default so jobs without license limits pay no sort cost;
# turn on when using HOST limits. Default = false.
dispatcher.limit.affinity_ordering_enabled=false
# DEPRECATED. Superseded by the failure rule on the limit itself. Statuses claimed by a limit take
# precedence; any left here and unclaimed still work, with a one-time WARN at startup.
dispatcher.layer_delay.rules=
limit.usage_refresh_seconds also drives the LimitRuleCache refresh interval. Caching failure
rules in-process is safe where caching usage counters would not be: rules are read-only operator
configuration changed by hand a few times a year, so a few seconds of staleness after an edit is
invisible and every Cuebot instance converges on the next tick.
Migrating from dispatcher.layer_delay.rules
The property still works for statuses no limit claims, and Cuebot logs a WARN at startup naming each entry. Move each one onto the limit it belongs to:
limit.setFailureRule(exitStatus=330, delayMinutes=5, autoTag=True)
A limit claiming a status overrides any property entry for it — including a limit with
delay_minutes = 0, which deliberately turns the delay off while keeping discovery on.
8. API
Proto — proto/src/limit.proto
Enums: LimitType (FRAME, HOST), LimitEnforcement (ENFORCED, ADVISORY, DISABLED),
LimitHoldSource (CUE, EXTERNAL, BOTH), LimitBindSource (SPEC, AUTO, MANUAL).
The Limit message gained the configuration fields above plus the usage split that an operator
actually needs to read:
| Field | Meaning |
|---|---|
current_usage |
Merged usage: settled + pending. What the dispatcher gates on. |
settled_usage |
Reported by the license server as of last_report_time. |
pending_usage |
Booked since, not yet visible to the server. Persistently high means the reporter is behind, not that the farm is busy. |
host_count |
Distinct hosts holding at least one token. |
report_stale |
The last report is older than the TTL. |
blocking_disabled |
The limit is not currently blocking, for any reason. Saves clients recomputing the rule. |
spec_layer_count / auto_layer_count |
Coverage, split by how it was acquired. |
current_running (field 4) is retained as a deprecated alias of current_usage for wire
compatibility.
New RPCs on LimitInterface:
| RPC | Purpose |
|---|---|
SetType |
Switch between FRAME and HOST counting. |
SetEnforcement |
ENFORCED / ADVISORY / DISABLED. |
SetSoftValue |
The spread threshold. 0 or -1 means “same as max”. |
SetReportTtl |
Staleness threshold in seconds; 0 disables. |
SetFailureRule |
Exit status, backoff and auto-tag in one call — they are only meaningful together. |
GetBindings |
Layers bound to a limit, filterable by origin and by layer id. |
ClearBindings |
Remove bindings scoped by origin. SPEC is never removable this way. |
ReportUsage |
Feed Cuebot the license server’s view. |
GetHolds |
Current token holders, filterable by limit and by host. |
ReportUsage semantics
- Per-limit full snapshot, applied in one transaction, advancing that limit’s watermark. Deltas would be unrecoverable if one were lost.
- Limits absent from the request are untouched. A Houdini reporter must not affect the Nuke limit.
- An empty
hostslist is meaningful: it clears the hold set. That is the correct way to say “nothing is checked out”, and it is distinct from not mentioning the limit at all. - Unknown limit names are returned, not thrown, in
unknown_limits. A license server reports every product the vendor sells; the farm has limits for three of them. - Skips are returned, not thrown, in
skipped_limitswith a reason:RATE_LIMITED— another reporter posted insidelimit.min_report_interval_seconds.OUT_OF_ORDER— the snapshot is older than the limit’s current watermark. The reporter’s clock is behind, or a queued snapshot was replayed out of order; applying it would move the watermark backwards and read as stale immediately.
Either way the rest of the batch still applies. Validation of the report contents happens in a pre-flight pass over every report, so a bad row rejects the request with nothing written rather than rolling back a partially-applied batch.
- Hostname normalization is Cuebot-side: strip domain, lowercase, trim. Reporters should not need to know Cue’s naming conventions. The raw string is retained for display.
capture_time matters more than it looks. The watermark decides which bookings count as pending; if
a reporter takes 20 seconds to run, stamping receipt time silently discards 20 seconds of bookings
from the pending set. Reporters should send the time they polled the server, not the time they
finished.
SetFailureRule validation
exit_statusmust be0(clear) or> 1. Status 1 and negative values returnINVALID_ARGUMENT.exit_statusmust not be claimed by another limit →ALREADY_EXISTS, naming the other limit.delay_minutes >= 0. Zero is valid and means “tag but never delay”.
pycue
import opencue
from opencue_proto import limit_pb2
# Create a discovery-mode license limit.
limit = opencue.api.createLimit('houdini', 30,
limitType=limit_pb2.HOST,
enforcement=limit_pb2.ADVISORY,
exitStatus=330, delayMinutes=2)
# Configuration
limit.setLimitType(limit_pb2.HOST)
limit.setEnforcement(limit_pb2.ADVISORY)
limit.setSoftValue(25)
limit.setReportTtl(900)
limit.setFailureRule(exitStatus=330, delayMinutes=2, autoTag=True)
# Inspection
limit.currentUsage() # merged: what gates
limit.settledUsage() # the license server's number
limit.pendingUsage() # booked since the last report
limit.hostCount() # distinct holders
limit.isReportStale() # report older than the TTL
limit.isBlocking() # False for ADVISORY, DISABLED, and stale ENFORCED
limit.specLayerCount(), limit.autoLayerCount()
limit.holds() # who holds a token
limit.bindings(sources=[limit_pb2.AUTO]) # what Cue thinks needs it
limit.clearBindings([limit_pb2.AUTO]) # the undo for a mis-set exit status
# Module-level equivalents
opencue.api.getLimitHolds(limitName=None, hostName=None)
opencue.api.getLimitBindings('houdini', sources=None, layerIds=None)
opencue.api.clearLimitBindings('houdini', [limit_pb2.AUTO])
Reporting:
report = opencue.api.buildLimitReport(
'houdini',
{'render0142': 1, 'ws-dtavares': 1},
capture_time=captured_at) # stamp BEFORE polling the server
response = opencue.api.reportLimitUsage([report], source='sesictrl@lic01')
response.unknown_limits # names with no matching limit; not an error
response.skipped_limits # rate-limited or out-of-order; not an error
rest_gateway
main.go already registers gw.RegisterLimitInterfaceHandlerFromEndpoint, which generates a route
per RPC from the proto. The new RPCs are exposed over REST with no gateway change — a useful path for
reporters not written in Python.
9. CueGUI
Limits plugin
The tree gained columns for Type, Mode, Soft, the three-way usage split, Hosts, Free, Last Report, Source, Error Code, Backoff and Layers. State is encoded in form as well as number:
- Mode shows
Advisory (stale)when the report has aged out, so an operator never has to work out from three columns whether a limit is actually gating. - Last Report turns red past the TTL. A dead cron is otherwise invisible.
- Layers reads
120 (+38 auto). While the auto count climbs, coverage is still converging and the limit is not ready to be Enforced.
Context menu:
Edit Max Value… Show License Holders…
Edit Soft Threshold… Show Tagged Layers…
Set Type ▸ Remove Auto-Tagged Layers…
Set Mode ▸ ─────────
Set Report Timeout… Rename
Set Failure Rule… Delete Limit
LimitHoldsDialog (also opened by double-clicking a limit) lists Host / Tokens / Source / User /
Reported. Render-host rows jump to the host monitor; workstation rows are inert. The footer states
the arithmetic plainly, because the settlement split is the one thing nobody will guess:
31 of 50 in use — 28 reported by the license server, 3 booked since. 24 render hosts, 7 workstations. Packing above 30. Reported by sesictrl@lic01, 14s ago.
LimitBindingsDialog is the counterpart: holds answer “who is using this license”, bindings
answer “what does Cue think needs it”. Filterable by origin, with a grouped-by-service footer that
makes a promotion candidate obvious — 38 auto-bound layers, 34 of them service "houdini" reads as
“make this a service default” without anyone running a query.
CreateLimitDialog replaces the old bare text prompt that hardcoded maxValue = 0 — a limit that
blocked everything the moment it was attached. It now walks through type, maximum, soft threshold,
mode, report timeout and the failure rule, with the Host-limit maximum explained inline as how many
machines.
Layer properties mark auto-bound limits, so a lighting TD looking at a delayed layer can see that Cue inferred the binding rather than the submitter declaring it — otherwise an auto-tag looks like someone else’s mistake.
Host monitor
A Licenses column shows what each host currently holds, with externally-held names parenthesized so an artist session on a render host is visible:
houdini,(mari)
The map is rebuilt in _getUpdate from a single farm-wide getLimitHolds() call — one extra RPC per
refresh, independent of farm size. A failed call keeps the last good map rather than replacing it, so
a hiccup cannot silently reinterpret “no data” as “nobody holds anything”.
The filter bar accepts license:houdini, filtered client-side against the same map. No proto change,
and consistent with how the column is populated. HostAttributes gains a Licenses group listing each
held limit with tokens, source and report age, closing the navigation loop from a holder row.
10. Metrics
| Metric | Type | Meaning |
|---|---|---|
cuebot_limit_auto_tag_total{limit} |
counter | Layers auto-bound after failing with the limit’s exit status. Ticks once per new binding, so it measures discovery rate, not failure rate. |
cuebot_limit_delays_total{limit,exit_status} |
counter | Automatic layer backoffs written by a failure rule. |
cue_limit_bound_layers{limit,source} |
gauge | Coverage, split SPEC / AUTO. |
cue_limit_usage{limit,kind} |
gauge | Usage, split settled / pending. |
cue_limit_report_stale{limit} |
gauge | 1 when the external report is older than its TTL. |
What to watch:
cue_limit_report_stalesustained at 1 — the reporter is dead and the limit has stopped blocking. Alert on this.cue_limit_bound_layers{source="AUTO"}plateauing — discovery has converged; the limit is a candidate forENFORCED.cue_limit_bound_layers{source="AUTO"}climbing by thousands per hour — a mis-set exit status. Clear the rule and runClearBindings.cue_limit_usage{kind="pending"}persistently large relative tosettled— the reporter is behind, not the farm busy. Shorten the poll interval or the settle window.
The gauges are cleared before each collection, so a deleted limit does not linger as a stale series.
11. External license reporting
A reporter polls the license server and pushes its per-host view into Cuebot via ReportUsage.
samples/licensing/ contains a reference implementation for SideFX sesinetd via sesictrl:
sesictrl_report.py, sesictrl_limits.yaml and a README.
The shape any reporter should follow:
capture_time = now() # BEFORE polling, not after
snapshot = poll license server
reports = [buildLimitReport(limit, hosts, capture_time=capture_time) for ...]
reportLimitUsage(reports, source='sesictrl@lic01')
Requirements that are not obvious:
- Never post a partial or empty snapshot on failure. An empty report clears every hold and opens
the gate wide. Going quiet instead lets Cuebot’s staleness handling degrade the limit to advisory,
which is the designed response. The sample script deliberately posts nothing on any error —
sesictrlfailure, parse failure, anything. - Do not let runs overlap. Two runs posting different snapshots would flap the dispatch gate. Use
flockunder cron, orType=oneshotunder a systemd timer. - Keep
limit.settle_window_secondsat roughly twice the poll interval, and each limit’sreport_ttlcomfortably above it, so a brief outage does not flap the stale flag. - A limit in
skipped_limitsis normal, not an error. Another reporter won the race insidelimit.min_report_interval_seconds; every other limit in the batch still applied. Log it and exit zero. - Validate the parser against a captured sample before going live. SideFX does not publish the
sesictrlJSON schema and it varies between Houdini versions. The sample script isolates all of it in one function,parse_sesictrl_json(), and supports--from-fileplus--dry-run.
A systemd timer is preferred over cron: real logging, a restart policy, no flock needed, and it can
poll faster than cron’s one-minute floor.
12. Rollout
The implementation lands in four independently shippable phases.
Phase 1 — schema and API. V49, proto, DAO, limit_usage + maintenance task, servant, pycue.
str_type defaults to FRAME, str_enforcement to ENFORCED, int_exit_status to NULL,
limit_host starts empty, and with no reports the pending scan reproduces today’s counting. The
layer_limit dedupe and unique constraint land here, ahead of anything that writes bindings. No
behavior change for any existing limit.
Phase 2 — dispatch. The enforcement/soft-threshold predicate and the affinity ordering across all
copies of the frame dispatch queries. This is where measurement happens: EXPLAIN (ANALYZE, BUFFERS)
against a production-sized snapshot.
Phase 3 — failure rules and discovery. LimitRuleCache, applyLimitRule, idempotent addLimit
with provenance, the property deprecation shim, discovery metrics. Safe to ship before phase 2 —
discovery with no gate changes nothing about booking.
Phase 4 — GUI and reporter. Limits widget, dialogs, host monitor column, samples/licensing/.
Then, per limit: create it ADVISORY with auto_tag on, watch coverage plateau, compare
settled_usage against the license server, and only then consider ENFORCED.
Backwards compatibility
| Surface | Guarantee |
|---|---|
Limit.current_running (field 4) |
retained, populated with merged usage |
Limit.max_value, id, name |
unchanged |
createLimit(name, maxValue) |
unchanged signature; ENFORCED FRAME limit |
| Existing limits | FRAME + ENFORCED; identical behavior |
b_host_limit = true rows |
migrated to str_type = 'HOST'; column retained, unused |
| Older Cuebot ↔ migrated database | works; the migration adds only columns, tables and constraints |
| CueGUI ↔ older Cuebot | new columns render 0 / --; no crash |
| Older CueGUI ↔ new Cuebot | unaware of new fields; works unchanged |
rest_gateway |
no change; new RPCs exposed automatically |
layer_limit rows |
all become SPEC; duplicates collapsed by the migration |
LayerDao.addLimit(layer, id) |
retained as a deprecated overload defaulting to SPEC |
dispatcher.layer_delay.rules |
still honored for unclaimed statuses; WARN at startup |
Two hard gates: limit_record name uniqueness (fails loudly — resolve by hand) and the layer_limit
duplicate collapse (repaired automatically, because the rows are provably redundant).
13. Troubleshooting
The farm stalled the moment I set a limit to ENFORCED.
Coverage was still converging. Every new AUTO binding raises measured usage without changing actual
license consumption. Set it back to ADVISORY, wait for cue_limit_bound_layers{source="AUTO"} to
plateau, and compare settled_usage against the license server before trying again.
A limit shows Advisory (stale) but I configured it Enforced.
The reporter has not posted within report_ttl. Check the reporter’s logs and its timer. A stale
limit deliberately stops blocking.
Pending is always large and Settled is always small.
The reporter is behind, not the farm busy. Either it polls too slowly for limit.settle_window_seconds,
or it is stamping capture_time after the poll instead of before.
Thousands of layers suddenly got auto-bound.
A mis-set exit status. Clear the rule with setFailureRule(exitStatus=0), then
clearBindings([limit_pb2.AUTO]). SPEC bindings are untouched.
Every relaunch of a job stalls once on the same layer.
Expected: coverage is per-layer and does not survive job launch (§5.6). Lower delay_minutes to 1–2,
and consider declaring the limit in the job spec for that service.
Two limits want the same exit status.
SetFailureRule returns ALREADY_EXISTS naming the other limit. One status, one limit — there is no
sensible resolution for two.
A host is refused work it should be able to take, on a local (workstation) booking.
Local dispatch gates on max_value alone and does not run the holder test. See the caveat in §3.4.
Related documentation
- Adding or removing limits — the operator-facing guide
- Deferred Layer Booking with Start After —
the
ts_start_aftergate the failure rule writes to - RQD log-based exit-status rules — how RQD recognises a license shortage from the frame log and reports a substitute exit status
- Monitoring development guide — Prometheus setup