sqi-worker is configured through four layers applied in order, with later
layers overriding earlier ones:
- Built-in defaults — sensible values for local development.
- Config file — YAML or JSON; searched in
./config/sqi-worker.yaml,~/.sqi/sqi-worker.yaml, and/etc/sqi/sqi-worker.yamlby default. Pass an explicit path with--config /path/to/file. - Environment variables — prefixed
SQI_WORKER_, e.g.SQI_WORKER_NATS_URL. (Exceptions:diagnostics.enabledusesSQI_DIAGNOSTICS_ENABLEDandstaging.defaultsusesSQI_STAGING_DEFAULTS, both with noWORKERinfix — see thediagnosticsandstagingsections.) - CLI flags — highest priority.
--config/-c,--log-leveland--log-formatare root flags available on every subcommand;--dry-runand--nats-insecure-skip-verifybelong tostartonly.
Print the effective merged configuration at any time with:
sqi-worker config printValidate configuration without connecting to the server:
sqi-worker start --dry-runA fully commented example file is at
config/sqi-worker.example.yaml.
Duration values use Go syntax: 30s, 1m30s, 500ms, 2h, etc.
| Type | string |
| Default | "" (empty — use mDNS discovery) |
| Env var | SQI_WORKER_NATS_URL |
URL of the NATS server embedded in sqi-server. Required when
discovery.enable_mdns is false. Must not be empty unless mDNS discovery
is enabled. Example: nats://sqi-server.local:4222.
nats:
url: "nats://sqi-server.example.com:4222"| Type | string |
| Default | "" (disabled) |
| Env var | SQI_WORKER_NATS_TLS_CERT_FILE |
Path to the client TLS certificate (PEM-encoded). Required for mutual TLS
when the server demands client certificates. Must be set together with
nats.tls_key_file.
nats:
tls_cert_file: "/etc/sqi/client.crt"
tls_key_file: "/etc/sqi/client.key"| Type | string |
| Default | "" (disabled) |
| Env var | SQI_WORKER_NATS_TLS_KEY_FILE |
Path to the client TLS private key (PEM-encoded). Must be set together with
nats.tls_cert_file.
| Type | string — "auto", "true" or "false" |
| Default | "auto" |
| Env var | SQI_WORKER_NATS_TLS_ENABLED |
Whether to use TLS on the broker connection. Three-valued, mirroring the
server's auth.session.cookie_secure:
| Value | Meaning |
|---|---|
"auto" |
Use TLS when there is a reason to: any tls_* field is set, or the server discovered over mDNS advertises a TLS-required broker (nats_tls=1) |
"true" |
Always use TLS, even with no CA configured — verification then uses the system roots, which is right for a publicly-trusted broker certificate and wrong for a farm CA |
"false" |
Never use TLS, even with a CA configured |
A bool cannot express this: the interesting states are infer and force off, and a bool collapses both onto its zero value.
The usual boolean spellings are accepted as synonyms in both the config file and
the environment — 1/yes/on for "true", 0/no/off for "false" —
since SQI_WORKER_NATS_TLS_ENABLED=1 is how boolean-looking variables get set.
Anything else is rejected at startup with a nats.tls_enabled validation error,
rather than silently falling back to "auto".
Under "auto", a worker that discovers a TLS-required broker enables TLS and
says so in its startup log; if no tls_ca_file is configured it also warns that
the system roots cannot verify a farm CA. Under "false" the advertisement is
honored rather than overridden, but the worker warns that the connection is
about to be refused — the broker's own error names neither the cause nor this
setting.
nats:
tls_enabled: "auto"| Type | string |
| Default | "" (use system CA pool) |
| Env var | SQI_WORKER_NATS_TLS_CA_FILE |
Path to the CA certificate that verifies the broker's TLS certificate
(PEM-encoded), i.e. the certificate configured as nats.tls.cert_file on the
server.
Empty means the system certificate pool; set means that CA only, so
pointing this at a farm CA pins the acceptable issuer rather than widening the
set. Distinct from
nats.server_tls_ca_file, which verifies the HTTPS
API on a different port and may legitimately have a different issuer.
nats:
tls_ca_file: "/etc/sqi/ca.crt"| Type | bool |
| Default | false |
| Env var | SQI_WORKER_NATS_INSECURE_SKIP_VERIFY |
| CLI flag | --nats-insecure-skip-verify |
Skip TLS certificate verification. Never set this to true in production
— it defeats the purpose of TLS. Use only in development environments where a
self-signed certificate is acceptable.
| Type | int |
| Default | -1 (retry indefinitely) |
| Env var | SQI_WORKER_NATS_MAX_RECONNECT_ATTEMPTS |
Maximum number of reconnection attempts before giving up and exiting. -1
means retry indefinitely — the recommended setting for long-running workers.
Reconnect attempts use exponential backoff with jitter starting at
nats.reconnect_wait.
nats:
max_reconnect_attempts: -1
0weakens a diagnostic message, not just reconnect behavior. When broker authentication is on and the server rejects this worker's credential mid-run (most often aftersqi-server worker revokeorDELETE /api/v1/workers/{id}/credential), the worker names the cause and the remediation — but doing so relies on at least one reconnect attempt completing its handshake with the broker, which is where the specific rejection reason is confirmed. Withmax_reconnect_attempts: 0, no reconnect is attempted at all: the connection simply closes, and the operator sees only a generic "connection closed" with no named cause. The default of-1does not have this problem — reconnect indefinitely and the crafted diagnostic message is always reached before the worker exits. If you must cap reconnect attempts, keep it above0.
| Type | duration |
| Default | "2s" |
| Env var | SQI_WORKER_NATS_RECONNECT_WAIT |
Base wait duration between reconnection attempts. The actual delay uses
exponential backoff with ±20% jitter; this is the floor of that range. Must
be > 0.
nats:
reconnect_wait: "2s"| Type | string |
| Default | "" (resolves to <worker.data_dir>/worker.nk) |
| Env var | SQI_WORKER_NATS_CREDENTIAL_FILE |
Path to this worker's nkey seed file. Only meaningful when the server's
nats.auth.enabled is true — see
docs/configuration.md. The worker
presents the credential in this file to authenticate to the broker. When
left empty, it defaults to <worker.data_dir>/worker.nk, and that default
path is created by enrollment or by sqi-worker keygen. The file must be
mode 0600, and SaveSeed requires write permission on the containing
directory, not just the file itself — the write goes through a temporary
file created alongside the target and then renamed into place, so a
directory locked down to e.g. 0500 fails the write even if the seed file
inside it is owner-writable.
Set this field to a non-default path and both enrollment paths honor it:
sqi-worker keygen loads the same layered configuration sqi-worker start
does (the root -c/--config file, SQI_WORKER_NATS_CREDENTIAL_FILE, and
this field's config-file value) and writes wherever credential_file
resolves, and self-service enrollment writes to the same resolved path as
part of a live enrollment run.
nats:
credential_file: "/var/lib/sqi-worker/worker.nk"| Type | string |
| Default | "" |
| Env var | SQI_WORKER_NATS_JOIN_TOKEN |
A worker enrollment token, used exactly once on first start to obtain a
credential. Ignored once nats.credential_file already exists. Prefer
nats.join_token_file over this field — a token in a
config file is a secret at rest.
nats:
join_token: ""| Type | string |
| Default | "" |
| Env var | SQI_WORKER_NATS_JOIN_TOKEN_FILE |
Path to a file containing a join token. Takes precedence over
nats.join_token.
nats:
join_token_file: "/run/secrets/sqi-join-token"| Type | string |
| Default | "" |
| Env var | SQI_WORKER_NATS_SERVER_URL |
sqi-server HTTP base URL used for enrollment, e.g.
"http://sqi-server.example:8080". Enrollment runs over REST, not NATS: the
broker's job is to reject unauthenticated connections, so it cannot also be
the channel a worker gets its first credential over.
This is not derived from mDNS discovery — it must be set explicitly
whenever nats.join_token or
nats.join_token_file is configured. A worker that
needs to enroll with no server_url set fails fast with an actionable error
naming this field, rather than attempting a request with no host.
When the server has http.tls enabled this must be an https:// URL, and
nats.server_tls_ca_file must name the CA that
signed its certificate.
nats:
server_url: "https://sqi-server.example:8080"| Type | string |
| Default | "" (use system CA pool) |
| Env var | SQI_WORKER_NATS_SERVER_TLS_CA_FILE |
Path to the CA certificate that verifies sqi-server's HTTPS certificate at
nats.server_url, for enrollment.
Deliberately separate from nats.tls_ca_file: the two
verify different certificates on different ports, which can legitimately come
from different issuers — a publicly-issued certificate on the API and a private
farm CA on the broker. On a farm generated by sqi-server tls init they are
simply the same ca.crt.
This is on the bootstrap critical path. Enrollment runs over REST before the worker holds any broker credential, so without it a farm with an HTTPS server cannot bootstrap at all. Same CA semantics as everywhere: empty means the system roots, set means that CA only.
nats:
server_tls_ca_file: "/etc/sqi/ca.crt"| Type | bool |
| Default | false |
| Env var | SQI_WORKER_NATS_SERVER_TLS_INSECURE_SKIP_VERIFY |
Skip verification of sqi-server's HTTPS certificate during enrollment.
Development environments only — it accepts any certificate, which makes the
enrollment request, join token included, interceptable.
| Type | string |
| Default | os.Hostname() |
| Env var | SQI_WORKER_NAME |
Human-readable label for this worker shown in the sqi-server web UI and
logs. Defaults to the machine hostname. Use a descriptive name on farms with
many workers of the same type, e.g. render-gpu-01.
worker:
name: "render-gpu-01"| Type | string |
| Default | "" (no farm) |
| Env var | SQI_WORKER_FARM_ID |
Farm this worker belongs to. When set, the worker only receives tasks belonging to that farm. When empty (the default), the worker is unaffiliated and accepts tasks from any farm — suitable for single-farm or development setups. Set this when running workers across multiple farms to prevent cross-farm task assignment.
worker:
farm_id: "studio-a"| Type | string |
| Default | ~/.sqi/worker (Linux/macOS); %USERPROFILE%\.sqi\worker (Windows) |
| Env var | SQI_WORKER_DATA_DIR |
Directory used to persist the worker ID file (worker.id), and on Windows the
DPAPI-encrypted run-as-user credential store (<data_dir>\isolation\). Created
automatically on first start, and never widened for run-as-user traversal —
it stays private (0700) for as long as the worker exists.
The worker ID file ensures the server can correlate this worker across
restarts. Do not delete worker.id unless you intend to re-register as a new
worker. For production, use an absolute path on a fast local SSD.
Each worker instance needs its own data_dir: two workers sharing one would
load the same worker.id and collide on the server. This is the key setting
when running multiple workers on one host.
Session working directories have their own setting — see
worker.session_dir below. They are moved out from under
data_dir for any worker that could actually use run-as-user isolation (a root
POSIX worker, or any Windows worker); a non-root POSIX worker with
session_dir unset still keeps them at <data_dir>/sessions, the pre-split
location, because isolation cannot function there anyway.
worker:
data_dir: "/var/lib/sqi-worker"| Type | string |
| Default | "" → resolved at startup (see below) |
| Env var | SQI_WORKER_SESSION_DIR |
Directory under which session working directories are created
(<session_dir>/<sessionID>/). Deliberately separate from data_dir: this
is ephemeral scratch that, when run-as-user isolation (isolation: in the
config file — see config/sqi-worker.example.yaml) is in use, must be
traversable by whichever run-as-user identity a session resolves to, while
data_dir holds the persistent worker-id and must stay private.
Left unset, the effective value is resolved at startup:
- Running as root (POSIX) —
/var/lib/sqi-worker-sessions, created traversable (0711) from birth. Deliberately a SIBLING of, never a descendant of,data_dir's own HOME-unset fallback (/var/lib/sqi-worker): nesting the two would makeLoadOrCreateWorkerID's own0700data_diran ancestor of the session root, and the boot-time traversal check below would then refuse to start over a directory sqi itself just created. Its ancestors (/var,/var/lib) are0755on every real Linux/macOS installation, so nothing needs to be created or widened specifically for this. - Windows (any account) —
%ProgramData%\sqi\worker\sessions, chosen regardless of privilege: a worker running as LocalSystem resolves its data directory underSystem32\config\systemprofile, which is the wrong place for render scratch. Directory modes are inert on Windows; a session directory's real protection is the protected NTFS DACL applied beneath this root (see Windows below). - Otherwise (non-root POSIX) —
<data_dir>/sessions, created at0750(the location and mode used before this split existed). Real run-as-user isolation cannot function without root regardless of directory permissions, so there is nothing to protect by moving it, or widening it, for a worker that can never use it anyway.
worker:
session_dir: "/var/lib/sqi-worker-sessions"Never silently widened. Unlike an earlier implementation, sqi will not
chmodan existing directory to make it traversable — a directory is either created fresh at the correct mode, or the worker refuses to start with an actionable error naming the exact ancestor that needschmod o+xand why (seeinternal/worker/isolation.ValidateTraversable). At boot, this check runs only whenisolation.required: true— an operator who never sets that on a worker that happens to run as root (but was never actually going to receive an isolated assignment) must still be able to start with a deliberately restrictedstaging.scratch_dirorsession_dir. Otherwise, the identical check (same actionable message) runs the moment an assignment that actually carries run-as-user isolation arrives, failing that one task rather than the whole worker.
Upgrade note: a root worker's sessions relocate automatically, with no config change. The root/non-root branch above is decided purely by the worker's effective uid at startup, not by whether any queue actually configures
run_as_user— a worker that has always run as root (for whatever reason, isolation-related or not) and never setsession_dirmoves from the pre-split<data_dir>/sessionsto/var/lib/sqi-worker-sessionsthe moment it is upgraded to a version that contains this split, with no config change and no explicit opt-in. This is intentional, not a regression to work around by settingsession_dirback to the old path: keeping sessions underdata_dirwould eventually demandchmod o+xondata_diritself for the first isolated assignment, widening the0700directory that holds the worker-id file — exactly what this split exists to prevent. Two concrete consequences to plan for before upgrading a root worker:
- Any directories preserved under the old
<data_dir>/sessionsbyworker.keep_failed_sessionsare orphaned — they still exist on disk for post-mortem inspection, but the worker will never look there again after the upgrade. Copy out anything you still need before upgrading, or check both paths for a time.- The new default,
/var/lib/sqi-worker-sessions, sits on the root filesystem, not necessarily the volumedata_dirpoints at. A deployment that intentionally placeddata_diron a large/fast dedicated volume (thedata_dirdoc above recommends "an absolute path on a fast local SSD") gets session scratch on/varinstead unlesssession_diris set explicitly to point back at that volume.
| Type | string |
| Default | "" (none) |
| Env var | SQI_WORKER_COMPUTE_LOCATION |
Named compute location for this worker. When non-empty, sqi-server
auto-registers an entry for this name in the compute-location registry if one
does not already exist — you do not need to pre-create the entry.
The value is used for two purposes:
- Storage-location path mapping — the server resolves
loc://URI references using the root keyed by this name in each storage location (seedocs/storage-locations.md). - Step affinity matching — steps that declare
attr.worker.computelocation: [<name>]in their OpenJD host requirements are only assigned to workers whosecompute_locationmatches.
Leave empty if you are not using named storage locations and no steps declare a compute-location requirement.
worker:
compute_location: "nas-studio-a"See docs/compute-locations.md for the full guide,
including the auto-registration model, non-blocking deletes, and the
relationship to storage-location roots.
| Type | []string |
| Default | [] (empty) |
| Env var | SQI_WORKER_CAPABILITY_TAGS (comma-separated) |
Arbitrary capability tags merged with auto-detected capabilities at registration time. Use these to annotate software or hardware features that the auto-detector cannot discover. Tags listed here overwrite any auto-detected tag with the same key.
worker:
capability_tags:
- maya-2025
- arnold-7
- gpu
- highramSQI_WORKER_CAPABILITY_TAGS=maya-2025,arnold-7,gpu sqi-worker startSee docs/worker-capabilities.md for the full
reference including auto-detected tags.
| Type | duration |
| Default | "15s" |
| Env var | SQI_WORKER_HEARTBEAT_INTERVAL |
How often the worker publishes a heartbeat message to sqi-server. The
server uses heartbeat gaps to detect stale workers — this value should be
shorter than the server's scheduler.heartbeat_timeout (default 30 s). At
the default of 15 s the server receives two heartbeats per timeout window.
Must be > 0.
worker:
heartbeat_interval: "15s"| Type | duration |
| Default | "30s" |
| Env var | SQI_WORKER_SHUTDOWN_GRACE_PERIOD |
Maximum time the worker waits for in-flight tasks to finish after receiving
SIGINT or SIGTERM before force-killing them. Tasks that do not complete within
this window receive SIGTERM then SIGKILL and are reported as failed. Set
this to match your longest expected task duration on rolling-restart workers.
Must be > 0.
worker:
shutdown_grace_period: "5m"| Type | bool |
| Default | false |
| Env var | SQI_WORKER_ALLOW_ROOT |
Allow the worker to run as the root user on Linux and macOS. Disabled by default because executing render processes as root is a security risk. Enable only when running inside a container where root is expected (e.g., as the container's only user), or when you understand and accept the risk.
worker:
allow_root: false| Type | bool |
| Default | false |
| Env var | SQI_WORKER_KEEP_FAILED_SESSIONS |
Retain a session's working directory after a failed session (task cancellation, non-zero exit code, or environment setup error). Useful for post-mortem inspection of partial outputs and environment state on a specific worker. Disable in production to avoid filling the data directory on busy workers.
worker:
keep_failed_sessions: false| Type | []string |
| Default | [] (all queues) |
| Env var | SQI_WORKER_QUEUE_IDS (comma-separated) |
Restrict this worker to serving specific queue IDs. The worker keeps one
outstanding lease request per listed queue (work.lease.<workerID>.<queueID>). When
empty (the default), the worker issues a single lease request on the reserved
subject work.lease.<workerID>._any — an empty queue token would produce an
unroutable subject with no responders. The server selects tasks farm-wide for that
token and gates by worker eligibility, so a queue-unaffiliated worker is
matched to any queue's ready work. Set this on heterogeneous farms where some
workers specialise in a subset of queues.
Each entry must be usable as a single NATS subject token, because it becomes
one: <queueID> is a literal token in work.lease.<workerID>.<queueID>, so
an entry containing . would silently split into extra tokens the
server-side parser and this worker's own broker-auth publish grant both
reject. An entry that is empty, or contains ., whitespace, * or >, is
therefore rejected at load, naming the offending entry: the worker
refuses to start rather than issuing lease requests that would be silently
refused or simply go unanswered with nothing in its logs saying why.
worker:
queue_ids:
- gpu-renders
- cpu-preview| Type | duration |
| Default | "2s" |
| Env var | SQI_WORKER_PULL_IDLE_BACKOFF |
Deprecated. This field is accepted by the config loader for backwards compatibility but has no effect in the current lease-based worker. Idle backoff is no longer needed: the worker's lease request long-polls on the server (~30 s hold) and re-issues immediately on return — there is no tight polling loop to throttle.
| Type | duration |
| Default | "5s" |
| Env var | SQI_WORKER_PULL_NACK_DELAY |
Deprecated. This field is accepted by the config loader for backwards compatibility but has no effect in the current lease-based worker. Pre-execution NACKs no longer apply: the server validates eligibility before leasing, so the worker runs what it is given.
Runs job-supplied task and environment actions under a queue-configured OS
user (run_as_user/run_as_group on the queue — see
docs/configuration.md
and docs/auth.md) instead of the worker daemon's
own account. Supported on POSIX (Linux/macOS) and on Windows via the
logon_user provider — see Windows below for the
platform-specific setup and requirements. With no queue configured for
isolation anywhere, a worker's behavior is byte-for-byte unchanged whether or
not this section is present.
Enabling this RAISES the worker daemon's own privilege. The only mechanism sqi has for a POSIX process to become another OS user (
setuid/setgid/setgroups) itself requires starting as root:isolation.Provider.Capable()fails unless the worker's effective uid is 0. If you runsqi-workerunprivileged today, turning isolation on is not a pure reduction in privilege — it is a trade. The daemon gains root so that individual tasks can lose it. Do not enableisolation.requiredor point any queue'srun_as_userat this worker expecting the daemon itself to end up with less access than it has now; it ends up with more.
| Type | bool |
| Default | false |
| Env var | SQI_WORKER_ISOLATION_REQUIRED |
Exit at boot if this worker cannot assume another OS identity (POSIX: not
running as root). Separate from a per-assignment credential-resolution
failure, which fails only that one task: required: true says "this worker
is misconfigured" — a problem the worker can only detect at boot, since it
learns queue identities solely from each assignment, never from its own
config. Default false, so a worker that never expects an isolated
assignment keeps starting normally even if it happens to run as root.
isolation.required: true is a contradiction with worker.allow_root: false
(the default) on POSIX: the POSIX provider can only assume another identity
from root, but worker.allow_root: false refuses to let the worker start as
root at all. Config validation catches this combination explicitly at load
time with a message naming both keys, rather than letting it surface as a
confusing "cannot start as root" error that never mentions isolation. Set
worker.allow_root: true together with isolation.required: true, or leave
isolation.required: false. Windows is exempt from this check: its
credential mechanism does not require the worker process itself to run
privileged.
isolation:
required: true
worker:
allow_root: true| Type | string |
| Default | "logon_user" |
| Accepted values | logon_user (s4u is recognised and refused; any other value fails Windows provider construction) |
| Env var | SQI_WORKER_ISOLATION_PROVIDER |
Selects the Windows credential mechanism. Ignored on POSIX — setting it
on a Linux/macOS worker has no effect at all, since the POSIX provider has no
concept of a selectable mechanism. logon_user is the only supported value;
s4u is refused explicitly (see Windows below).
| Type | []string |
| Default | [] (empty — only the minimal base is inherited) |
| Env var | SQI_WORKER_ISOLATION_ENV_PASSTHROUGH (comma-separated) |
Daemon environment variable name patterns (filepath.Match globs, e.g.
FLEXLM_*) that an isolated task's environment inherits from the worker
daemon's own process, in addition to a minimal base (PATH,
HOME/USERPROFILE, TMPDIR, and a few others rewritten to the target
user rather than left pointing at the daemon's). Each pattern is validated as
a glob at config-load time; an invalid one (e.g. an unbalanced [) fails
worker startup naming the exact field, isolation.env_passthrough[N].
This governs only what an isolated task inherits from the daemon's own
environment. Anything the job itself supplies — an OpenJD
Environment.variables block, an openjd_env export, a task template
variable — always reaches the task unfiltered, regardless of this setting;
those are the job's own data, not daemon leakage, and filtering them would
silently break jobs whose variable names no operator allowlist could
anticipate.
A render farm's licensing model is the realistic reason this exists — most license managers are configured via environment variable on the render node, and that variable has to survive into the isolated task:
isolation:
env_passthrough:
- "foundry_LICENSE" # Nuke
- "ARNOLD_LICENSE" # Arnold (Maya/Houdini)
- "solidangle_LICENSE" # Arnold, older variable nameA broad glob defeats the allowlist entirely.
env_passthrough: ["*"]re-opens the exact daemon-environment leak this filter exists to close, and so does anything that reads narrower but matches broadly in practice, like"*KEY*"— the daemon's own credentials, tokens, and internal service addresses all match a wildcard just as easily as a license variable does. Write the actual variable name. A task failing on a missing license variable is the expected, correct symptom of an allowlist that is too tight — the fix is to add that one name, not to widen the pattern.
Task isolation is implemented on Windows via the logon_user provider. The
full suite — make test-isolation-windows, which exercises real local
accounts, real NTFS ACLs, DPAPI credential storage, and a child process
actually launched under the target account's token — has now run and passes on
a real elevated host (tier 1 as elevated Administrator, tier 2 as SYSTEM via a
scheduled task). See Known gaps in docs/auth.md for the
caveats that remain.
The worker must run as a service under LocalSystem, or as an account
granted SeAssignPrimaryTokenPrivilege. Windows requires that privilege to
start a process under another account's token, and an elevated Administrator
does not hold it by default — this is the single most common cause of
isolation: worker cannot assume another OS identity on Windows. Capable()
reports it at boot with the fix named.
Each run-as-user account needs the "Log on as a batch job" right
(SeBatchLogonRight). The provider logs the account on with
LOGON32_LOGON_BATCH — the correct logon type for a service doing work on a
user's behalf, since unlike an interactive logon it does not require "Allow log
on locally", and unlike an S4U logon it does carry network credentials. Default
workstation policy grants that right to Administrators, Backup Operators and
Performance Log Users only, so a purpose-made standard account does not
hold it until you say so, and credential resolution fails with:
isolation: logon "render-svc": LogonUserW: Logon failure: the user has not
been granted the requested logon type at this computer.
Grant it in secpol.msc under Local Policies → User Rights Assignment → Log
on as a batch job. To script it, or on an edition that ships no secpol.msc
(Windows Home), call LsaAddAccountRights with the account's SID —
scripts/test-isolation-windows.ps1 does precisely that for its throwaway
accounts and is a working reference.
Provisioning credentials. Each run-as-user account needs its password stored on every worker that serves a queue configured for it. From an elevated shell:
sqi-worker isolation set-credential render-svc
The secret is read from stdin — never pass it as an argument, where it is
visible to every process on the host. It is encrypted with the machine DPAPI
key and written under <data_dir>\isolation\ with an ACL granting only SYSTEM
and Administrators.
Machine-scope encryption means the file ACL is the real boundary. Anything running on the host that can read the file can decrypt it. Do not relax the ACL, and treat a backup that copies it elsewhere on the same machine as a credential disclosure.
Session directories are secured with a protected NTFS DACL granting the
target account, SYSTEM, and Administrators — and nothing else. Inheritance is
stripped, so a session directory does not carry whatever BUILTIN\Users grant
its parent had. Ownership is deliberately not transferred to the target
account: a Windows owner implicitly holds WRITE_DAC and could otherwise
re-open its own session to other accounts.
Session root defaults to %ProgramData%\sqi\worker\sessions, not the
worker data directory — as LocalSystem, the data directory resolves under
System32\config\systemprofile, which is the wrong place for render scratch.
Override with worker.session_dir.
Bypass traverse checking. Windows grants SeChangeNotifyPrivilege to
Everyone by default, which is why sqi does not check ancestor permissions
the way it does on POSIX. If your environment strips that privilege, isolation
is refused at credential-resolution time with a message naming the policy —
sqi will not widen the directories above a session root for you.
The s4u provider is not implemented and is refused explicitly. An S4U
logon needs no password, but its token carries no network credentials: any UNC
path, mapped drive, or authenticated licence server fails as ANONYMOUS
inside the task.
Process containment. Windows task processes run inside a job object, so a grandchild orphaned before a cancel or timeout is still reaped. One consequence applies to every Windows worker, isolated or not: a running task is terminated when the worker process exits, including on a graceful service restart. Previously such processes were orphaned while the task was reclaimed and re-run elsewhere.
Open gap: session-directory TOCTOU on Windows. See Known gaps in
docs/auth.mdfor the staging race this enables.
Before any stripping or filtering happens, isolation.Provider.Resolve
refuses several requests outright:
- A
run_as_usernaming a known-privileged account (root,Administrator,SYSTEM, ...) or resolving to uid 0, regardless of name. - An explicit
run_as_groupnaming a known-privileged group (root,wheel,admin,sudo,sudoers,adm,docker,disk,shadow,staff,administrators) — a check on the group you asked FOR, distinct from the target account's ambient memberships covered below. - A
run_as_useraccount whose primary group is gid 0 — refused even with norun_as_groupset at all, since gid 0 is refused unconditionally regardless of which name a platform gives it (rooton Linux,wheelon macOS/BSD).
Isolation strips gid 0 (root) from a target account's supplementary groups
unconditionally. It does not strip any other, named group the account
already belongs to — docker, disk, shadow, or an in-house group. This
is deliberate: those memberships are the account's own pre-existing access,
typically how a render-farm account reaches project storage, and stripping
them "to be safe" would silently break exactly that access. The operator's
responsibility, correspondingly: do not point a queue's run_as_user at an
account that belongs to docker (a well-known one-step escalation to root)
or disk/shadow. Check with id <user> before assigning an account to
an isolated queue, the same as you would before granting it any other kind of
OS-level access.
sqi-worker ships with CGO_ENABLED=0, so Go's os/user package reads only
/etc/passwd and /etc/group directly and never consults NSS — an account
that only exists via a directory service (LDAP/AD, sssd, winbind) would
not resolve at all. To make run_as_user usable against such an account, sqi
falls back to shelling out to id/getent when the direct read fails, and
parses their output.
Two honest caveats, not resolved by testing that exists today:
- The
id/getentoutput parsers have only ever been exercised against canned, hand-written output. They have never been run against a real directory server. Verify a directory-backedrun_as_useraccount resolves correctly in your own environment before relying on it in production — there is no equivalent ofmake test-ldap/make test-oidcfor this path. - A worker sandboxed by systemd (or similar) in a way that blocks
execbreaks this fallback outright. If your unit file restricts syscalls or denies executing external binaries,id/getentcannot run, and directory-backed accounts fail to resolve even though the account itself is valid.
isolation:
required: false
env_passthrough:
- "foundry_LICENSE"
- "ARNOLD_LICENSE"
- "solidangle_LICENSE"Five keys bound what one assignment may spend evaluating OpenJD EXPR
expressions on this host, at task-execution time — after the server has
already accepted the job. They are separate from the server's own
submission-time limits
(openjd.expr_*):
each worker reads its own, which is what lets a heterogeneous farm size them
to each host's real memory.
These are always on. 0 is not "unlimited" — it is out of range, and an
out-of-range value is a startup failure, not a clamp.
These keys are live. Earlier revisions carried a "
EXPRis not accepted yet" callout here: the extension's registry status was in-progress, so no template declaringextensions: [EXPR]could be submitted and no assignment ever reached these limits. That is no longer true —EXPRis a supported extension, EXPR jobs are submitted, dispatched and executed, and every one of these keys now meters real work on this host. Seedocs/openjd-extensions/expr.md.
1. Tightening is not free here the way it is on the server. Every one of these five is compared against a server-side limit metering the same values one phase earlier, and this worker must not be tighter than the server it reports to:
| This worker's key | must be ≥ the server's |
|---|---|
expr.operation_limit |
openjd.expr_operation_limit |
expr.memory_limit |
openjd.expr_memory_limit |
expr.assignment_positions |
openjd.expr_template_positions |
expr.assignment_retained_bytes |
openjd.expr_template_retained_bytes |
expr.let_retained_bytes |
openjd.expr_template_retained_bytes |
The last row pairs a per-table limit with a template-wide one, because
the server meters no per-table scope. A whole template's retained bytes is a
valid upper bound on any one of its tables, so the comparison is conservative
— see expr.let_retained_bytes, which is where the
consequence of this key's low floor is spelled out.
This worker advertises all five when it registers, and the server refuses
to dispatch EXPR jobs to it while any of them is short — it does not accept
the job and then fail it here, once per task, naming a budget the submitter
never saw. So the cost of tightening past the server is that this host stops
being offered EXPR work; it keeps running everything else. The server logs
the reason when this worker registers, and flags any task no capable worker
exists for — the latter only while the server's unschedulable sweep is on
(scheduler.unschedulable_grace > 0, the default). With that sweep off, such
a task simply waits ready with nothing written on it, and the one-off
registration log line is the only signal — and because the server emits it,
it lands in the server's own log (Admin → Server log, component server), not
in this worker's diagnostics.
The relation is necessary, not sufficient. Phase 3 evaluates concrete values where the server had placeholders, so the same expression can legitimately cost more here than it did at submit — which is exactly why the shipped defaults are 100x the server's operation budget and 20x its memory budget rather than equal to them. Matching the server is the floor, not a guarantee: an accepted job can still exhaust a worker that passes every comparison, and no configuration on either side makes that impossible.
Two of the five defaults have zero headroom against the server's, so raise
the workers first. expr.assignment_positions (10,000) is exactly the
server's openjd.expr_template_positions default, and expr.let_retained_bytes
(10,000,000) is exactly its openjd.expr_template_retained_bytes default.
Raising either of those two server keys by any amount therefore withholds EXPR
work from every worker still on the shipped defaults, immediately and
farm-wide. The other three ship with real headroom (100x the server's operation
budget, 20x its memory budget, 2x its template-retained-bytes budget for
expr.assignment_retained_bytes). Roll the worker value out first, confirm the
registration WARN is gone, then raise the server.
2. operation_limit and assignment_positions multiply. The cumulative
operation ceiling for one assignment is their product — 10¹⁰ at the defaults
(1,000,000 x 10,000), and 10¹² if both are raised to their maxima
(10,000,000 x 100,000), 100x. Nothing counts operations cumulatively; the
product is a derived upper bound, not a measurement. memory_limit is not a
third multiplier on it: raising that lets one evaluation hold a larger value,
and measured on this branch it does not change what an operation costs in
time.
3. None of these bounds wall-clock time. Specification section 1.3.10
rule 3 prices a string operation at the value's length divided by 256, so
byte-heavy work is charged almost nothing:
("x" * 900000).upper() and ("x" * 900000).title() are charged the same
7,034 operations and differ by 9x in CPU (~6 ms vs ~58 ms), and a
regex over the same string is charged half as much for nearly the slowest
time — a ~17x spread in cost per operation. Raising a limit lengthens the
worst assignment this host can be asked to resolve, roughly in proportion, and
no value makes a slow one impossible. Unlike the server's, this cost is
charged to the task slot the assignment already occupies; see
the server's caveat 2
for the measured numbers, which apply per position here too.
4. The byte dimensions count cumulative allocation, not peak live
retention. A session that never holds more than a few MB live at once is
still charged the sum of every environment it enters. Sizing host RAM against
assignment_retained_bytes over-provisions; sizing that key against an
observed RSS under-bounds it. For real RAM, the number that matters is
50 x memory_limit — the structural ceiling of a single let: block, 1 GB at
the default — per concurrently-resolving task slot, not memory_limit itself.
| Type | int |
| Default | 1000000 |
| Range | 10000 – 10000000 |
| Env var | SQI_WORKER_EXPR_OPERATION_LIMIT |
Operations (specification section 1.3.10) one host-side expression evaluation may spend. The floor is the server's own default per-evaluation operation budget: below that, this worker would reject at execution what the server type-checked at submit.
expr:
operation_limit: 1000000| Type | int |
| Default | 20000000 |
| Range | 1000000 – 200000000 |
| Env var | SQI_WORKER_EXPR_MEMORY_LIMIT |
Live bytes (specification section 1.3.9) one host-side expression
evaluation may hold. See caveat 4: the per-let:-block structural ceiling is
50x this number, so size RAM against that product, not against this value.
expr:
memory_limit: 20000000| Type | int |
| Default | 10000 |
| Range | 2000 – 100000 |
| Env var | SQI_WORKER_EXPR_ASSIGNMENT_POSITIONS |
Expression positions (the command, one args entry, one embedded file, one
variable value) one assignment may resolve, summed across the task's own
symbol table and every environment the session enters. Charged at environment
entry only — teardown gets a fresh allowance per evaluation so a budget
that cannot avert the memory it charges for can never silently skip an
onExit (license check-ins, unmounts).
Must not be lower than the server's openjd.expr_template_positions. An
assignment's positions are a subset of its template's, so a lower value here
cannot run a job the server accepted; the server sees that from this worker's
registration and withholds EXPR work rather than failing it here. The floor of
2,000 sits just above a worked count of 1,841 positions for a generous session
(one task action plus 50 entered environments).
expr:
assignment_positions: 10000| Type | int |
| Default | 20000000 |
| Range | 2000000 – 200000000 |
| Env var | SQI_WORKER_EXPR_ASSIGNMENT_RETAINED_BYTES |
Bytes every let: block in one assignment may cumulatively retain, summed
across the task's symbol table and every environment's. The default is exactly
2x expr.let_retained_bytes — sized for a task plus one environment, each
near its own per-table ceiling.
Cumulative allocation, not peak live retention — see caveat 4.
expr:
assignment_retained_bytes: 20000000| Type | int |
| Default | 10000000 |
| Range | 1000000 – 100000000 |
| Env var | SQI_WORKER_EXPR_LET_RETAINED_BYTES |
Bytes one symbol table may hold live. Measured across the whole table,
not just its let: bindings — so a job whose own parameters are large spends
budget its let: block never asked for. A block that would push the table
past this stops there with an error rather than spending a fresh evaluation
budget per remaining binding.
It bounds one table, a scope the server's template-wide walk does not
meter separately — but it is advertised and compared all the same, against
openjd.expr_template_retained_bytes (caveat 1's last row). A whole
template's retained bytes is a valid upper bound on any one of its tables, so
the comparison is conservative in one direction only: it can withhold EXPR
work from a worker whose per-table limit was never going to be the binding
constraint. It is not sufficient in the other — see below.
Read this before lowering it. The floor of
1000000is a tenth ofopenjd.expr_template_retained_bytes' default. A worker set anywhere below the server's value for that key is offered no EXPR work at all until one of the two moves. That is the intended outcome, and it is the visible one — a registrationWARNand anunschedulable_reasonnaming both keys. What it replaced is the invisible one: before this comparison existed, such a worker accepted the assignment and failed every task of the job, once per task, over alet:block the server had already accepted.
Even with the comparison satisfied, this key can still reject an accepted
job — the accounting measures the whole table, and phase 3 binds concrete
values the server only had placeholders for ("x" * Task.Param.N costs
nothing at submit). Sizing it at exactly the server's value is the floor, not
a guarantee.
It may legally be set higher than expr.assignment_retained_bytes; the
tighter of the two simply becomes the effective one.
expr:
let_retained_bytes: 10000000Configures the eight built-in software detectors (Maya, Nuke, Houdini,
Blender, Mistika Boutique/Ultima, Mistika VR, Mistika Workflows, and ffmpeg)
that run automatically at startup and advertise a key=true tag with no
per-worker configuration, plus any custom detectors for in-house tools. Full
reference, including the detector schema and the tag/version model:
docs/worker-capabilities.md.
| Type | []Detector |
| Default | [] (empty — only the built-ins run) |
| Env var | — (config file only) |
Custom detectors, in the same schema as the built-ins. Structured detectors
have no environment-variable form — cloud fleets bake them into the worker
config file shipped in the image. Each entry is validated at config-load
time; an invalid detector (missing tag, zero checks, more than one check
primitive set, a bad os value, or a non-compiling regex) fails worker
startup with a descriptive error.
capabilities:
detect:
- tag: mytool
checks:
- exe: mytool
- path_glob: "/opt/mytool*/bin/mytool"
version:
from: "mytool(?P<v>[0-9.]+)"| Type | []string |
| Default | [] (empty — all built-ins run) |
| Env var | SQI_WORKER_CAPABILITIES_DISABLE (comma-separated, appended to any config-file entries) |
Built-in tag names to turn off, by exact tag (maya, nuke, houdini,
blender, ffmpeg, mistika, mistikavr, mistikaworkflows). Use this
when a built-in misfires on a nonstandard host layout — typically paired with
a capabilities.detect entry for the same tag that supplies more specific
checks.
capabilities:
disable: [blender]SQI_WORKER_CAPABILITIES_DISABLE=blender,nuke sqi-worker startRun sqi-worker capabilities to print every tag the worker would currently
advertise, with its source (auto, builtin:<tag>, custom, or manual) —
the fastest way to confirm a detector is (or isn't) firing.
Used by jobs that run a stage_locally delivery of the SQI_PATH_TRANSLATION
extension. scratch_dir and sync_command are operator-owned and have no
environment-variable form (config file only); cloud fleets bake them into the
worker config shipped in the image. defaults (with its SQI_STAGING_DEFAULTS
env var) controls whether an otherwise-unconfigured worker still runs
stage_locally jobs — see below.
| Type | string |
| Default | "" → <os.TempDir()>/sqi-staging whenever unset |
| Env var | — (config file only) |
Base directory for per-attempt staged copies. Leave unset to use the
platform temp directory (e.g. /tmp/sqi-staging on Linux/macOS,
%TEMP%\sqi-staging on Windows) — convenient for local/dev workers, but a
persistent, purpose-built scratch volume is recommended for production. This
fallback applies whenever scratch_dir is unset, independent of
staging.defaults; what staging.defaults controls is whether staging is
allowed to proceed at all on an otherwise-unconfigured worker (see below).
| Type | string |
| Default | "" → built-in copy when unset and staging.defaults is true |
| Env var | — (config file only) |
Command template invoked per path, with {src}, {dest}, and optional
{object_type} placeholders (e.g. rsync -a {src} {dest}). The same template
serves copy-in and copy-out.
Leave unset, or set explicitly to the sentinel value builtin, to use sqi's
built-in cross-platform copy instead of shelling out — sqi then copies bytes
itself (single file or recursive directory tree, preserving file mode but not
ownership/xattrs) rather than invoking an external command. Any other value is
treated as a shell command template exactly as before.
Explicitly setting sync_command: builtin selects the built-in copy — and,
via the TEMP-scratch fallback above, lets staging proceed with scratch_dir
left unset — even when staging.defaults is false. It's an intentional
per-worker opt-in, distinct from the automatic fallback described under
staging.defaults, so it does not log the one-time WARN either.
The built-in copy only moves bytes the worker can already reach (local disk or a filesystem already shared/mounted on that worker). It is a local/dev convenience, not a substitute for remote transfer. A farm whose workers span multiple compute locations (see
docs/compute-locations.md) needs a realsync_command(rsync,aws s3 cp, etc.) to move data between them — configure one explicitly rather than relying on the built-in copy.
sync_commandMUST NOT create hardlinks into scratch when run-as-user isolation is in use — e.g.cp -al, orrsync --link-dest. A hardlink IS the file: it shares one inode with whatever it links to, so chowning the scratch copy to the target run-as-user identity chowns the ORIGINAL file too, and no permission fix-up sqi applies afterward can separate the two — there is nothing left to distinguish.rsync -a(no--link-dest) and the built-in copy above are both safe: they always copy bytes into a fresh inode.
sync_commandMUST NOT dereference symlinks at either end, on stage-in or stage-out. sqi validates the scratch-side path before invoking the command (regular file, single hardlink, contained in scratch), but that check cannot see what the command itself does once invoked:rsync -apreserves a symlink,rsync -aLor plaincpfollow it. A command that follows a symlink a task planted at its declared output path hands the daemon'ssync_commandprocess — running as root — whatever that symlink points to, on stage-out, or writes through a symlink planted at the real destination path, on stage-in. This is entirely a property of the command template an operator chooses and is outside anything sqi can inspect or enforce.Be aware this residue also includes a race, and it is structurally unclosable for
sync_commandspecifically. sqi handssync_commanda path string, not an open file descriptor, so there is nothing for sqi to pin between validating the scratch-side path and the command actually opening it — a task whose process group is still alive after its own successful exit (nothing kills it on success; only the timeout/cancel paths do) can still own a scratch subdirectory and swap a hardlink into it after sqi's check passes and before the external command reads the file. The built-in copy (sync_commandunset orbuiltin) does not have this gap: it re-validates the file on the descriptor it actually opened, which pins the inode and cannot be swapped out from under it. That re-validation has no equivalent for an external command sqi merely invokes with strings — there is no descriptor to hand it and no way to make its ownopen()call observe what sqi already checked. Treat a configuredsync_commandas carrying this residual risk permanently when run-as-user isolation is in use, not as a gap that a smarter check could close.
| Type | bool |
| Default | true |
| Env var | SQI_STAGING_DEFAULTS |
When true (the default), a worker that hasn't configured scratch_dir and
sync_command at all still runs stage_locally jobs: it falls back to the
TEMP scratch directory and the built-in copy described above, logging a
one-time WARN the first time it does so. Set staging.defaults: false to
disable that automatic fallback — a worker with neither key set then fails
stage_locally jobs immediately with a pre-execution error, as every worker
did before this setting existed. (An explicit sync_command: builtin still
works even with defaults: false — see above.)
staging:
scratch_dir: /scratch/sqi
sync_command: "rsync -a {src} {dest}"
defaults: trueBehavior change on upgrade. Before this setting existed, any worker that ran a
stage_locallyjob withoutstaging.scratch_dirandstaging.sync_commandconfigured failed the task immediately with a staging error.staging.defaultsnow defaults totrue, so those same workers instead run the job using TEMP scratch and the built-in copy. Setstaging.defaults: false(orSQI_STAGING_DEFAULTS=false) to restore the old fail-hard behavior.
| Type | bool |
| Default | true |
| Env var | SQI_DIAGNOSTICS_ENABLED (note: no WORKER infix) |
When enabled (the default) the worker publishes its own slog output to the
ephemeral core-NATS subject worker.diag.<workerID>, which the server ingests
into its diagnostics ring buffer and surfaces in the web UI. Set to false to
suppress publishing (the worker still logs to stderr). This is the worker
counterpart to the server's diagnostics.buffer_size knob.
diagnostics:
enabled: trueSQI_DIAGNOSTICS_ENABLED=false sqi-worker start| Type | string |
| Default | "info" |
| Accepted values | debug, info, warn, error |
| Env var | SQI_WORKER_LOG_LEVEL |
| CLI flag | --log-level |
Minimum log level to emit. Use debug during initial deployment to verify
registration and task flow. Switch to info or warn in production.
log:
level: "info"| Type | string |
| Default | "json" |
| Accepted values | json, text |
| Env var | SQI_WORKER_LOG_FORMAT |
| CLI flag | --log-format |
Log output format. json is structured and machine-parseable — use it in
production so log aggregators (Loki, Datadog, Splunk, etc.) can index fields.
text is human-readable with aligned columns — use it during local
development.
log:
format: "json"The worker exposes a small HTTP server on loopback for container orchestration health probes and Prometheus metrics. This server is not exposed to the network by default.
| Path | Purpose |
|---|---|
/healthz |
Liveness probe — always returns 200 when the process is running |
/readyz |
Readiness probe — returns 503 when the NATS connection is not connected |
/metrics |
Prometheus metrics endpoint |
/debug/pprof/ |
Go profiling endpoints (only when metrics.enable_pprof: true) |
| Type | string |
| Default | "127.0.0.1:9091" |
| Env var | SQI_WORKER_METRICS_ADDR |
This listener is plaintext unless metrics.tls.enabled
is set — nats.tls_* covers the broker connection, not this one. It serves
metrics, health and (optionally) pprof, never credentials or job payloads, and
defaults to loopback. Turn TLS on together with any address reachable from
elsewhere.
TCP address the local HTTP server listens on. Use 0.0.0.0:9091 to expose
the endpoints to Prometheus scrapers on the network (ensure the port is
firewalled appropriately).
When running multiple workers on one host, give each instance a distinct port — otherwise the second worker fails to bind.
metrics:
addr: "127.0.0.1:9091"| Type | bool |
| Default | false |
| Env var | SQI_WORKER_METRICS_TLS_ENABLED |
Terminate TLS on the metrics/health listener. As with the server's listeners, there is no plaintext port when it is on — the listener upgrades in place.
Requires metrics.tls.cert_file and metrics.tls.key_file
(SQI_WORKER_METRICS_TLS_CERT_FILE / SQI_WORKER_METRICS_TLS_KEY_FILE); the
worker refuses to start without both, and refuses a pair it cannot load. The
worker needs a certificate of its own here — this listener is the worker's, not
the server's — so issue one with sqi-server tls issue --host <worker-host> or
use whatever your monitoring stack already trusts.
metrics:
addr: "0.0.0.0:9091"
tls:
enabled: true
cert_file: "/etc/sqi/certs/worker.crt"
key_file: "/etc/sqi/certs/worker.key"| Type | bool |
| Default | false |
| Env var | SQI_WORKER_METRICS_ENABLE_PPROF |
Expose Go runtime profiling endpoints at /debug/pprof/. Profiling data
reveals memory layout, goroutine stacks, and CPU hotspots — enable
temporarily for performance diagnosis, never in long-term production.
metrics:
enable_pprof: false| Type | bool |
| Default | true |
| Env var | SQI_WORKER_DISCOVERY_ENABLE_MDNS |
Enable mDNS-based sqi-server auto-discovery. When true and nats.url is
empty, the worker browses for _sqi._tcp services on the local network.
Disable on networks that prohibit multicast — most cloud VPCs, VLANs, and
container networks.
discovery:
enable_mdns: true| Type | duration |
| Default | "5s" |
| Env var | SQI_WORKER_DISCOVERY_MDNS_TIMEOUT |
Maximum time to wait for an mDNS discovery result before giving up. Increase
if the server is slow to respond on a congested network. Only relevant when
discovery.enable_mdns is true.
discovery:
mdns_timeout: "5s"Controls how task process stdout/stderr is batched and streamed to
sqi-server via NATS JetStream. Tune these values to balance NATS message
overhead against how live the web UI log viewer feels.
| Type | int |
| Default | 50 |
| Env var | SQI_WORKER_LOG_STREAMER_MAX_LINES_PER_CHUNK |
Maximum number of output lines batched into a single NATS message. A flush is triggered immediately when the buffer reaches this count. Increase for very verbose processes to reduce per-message overhead; decrease for better granularity when watching live log output.
log_streamer:
max_lines_per_chunk: 50| Type | int (bytes) |
| Default | 16384 (16 KB) |
| Env var | SQI_WORKER_LOG_STREAMER_MAX_BYTES_PER_CHUNK |
Maximum total byte count of line content in a single NATS message. A flush is triggered when the accumulated bytes reach this limit after adding a line. Guards against a single very long line producing an oversized message.
log_streamer:
max_bytes_per_chunk: 16384| Type | duration |
| Default | "500ms" |
| Env var | SQI_WORKER_LOG_STREAMER_FLUSH_INTERVAL |
Maximum time a line may sit in the buffer before being flushed regardless of the chunk size thresholds. Smaller values make the web UI log viewer feel more live at the cost of more frequent small NATS publishes on slowly printing processes.
log_streamer:
flush_interval: "500ms"| Key | Type | Default | Env var | CLI flag |
|---|---|---|---|---|
nats.url |
string | "" |
SQI_WORKER_NATS_URL |
— |
nats.tls_cert_file |
string | "" |
SQI_WORKER_NATS_TLS_CERT_FILE |
— |
nats.tls_key_file |
string | "" |
SQI_WORKER_NATS_TLS_KEY_FILE |
— |
nats.tls_ca_file |
string | "" |
SQI_WORKER_NATS_TLS_CA_FILE |
— |
nats.insecure_skip_verify |
bool | false |
SQI_WORKER_NATS_INSECURE_SKIP_VERIFY |
--nats-insecure-skip-verify |
nats.max_reconnect_attempts |
int | -1 |
SQI_WORKER_NATS_MAX_RECONNECT_ATTEMPTS |
— |
nats.reconnect_wait |
duration | 2s |
SQI_WORKER_NATS_RECONNECT_WAIT |
— |
nats.credential_file |
string | "" (<worker.data_dir>/worker.nk) |
SQI_WORKER_NATS_CREDENTIAL_FILE |
— |
nats.join_token |
string | "" |
SQI_WORKER_NATS_JOIN_TOKEN |
— |
nats.join_token_file |
string | "" |
SQI_WORKER_NATS_JOIN_TOKEN_FILE |
— |
nats.server_url |
string | "" |
SQI_WORKER_NATS_SERVER_URL |
— |
worker.name |
string | hostname | SQI_WORKER_NAME |
— |
worker.farm_id |
string | "" |
SQI_WORKER_FARM_ID |
— |
worker.data_dir |
string | ~/.sqi/worker (Linux/macOS); %USERPROFILE%\.sqi\worker (Windows) |
SQI_WORKER_DATA_DIR |
— |
worker.session_dir |
string | "" → resolved at startup |
SQI_WORKER_SESSION_DIR |
— |
worker.compute_location |
string | "" |
SQI_WORKER_COMPUTE_LOCATION |
— |
worker.capability_tags |
[]string | [] |
SQI_WORKER_CAPABILITY_TAGS |
— |
worker.heartbeat_interval |
duration | 15s |
SQI_WORKER_HEARTBEAT_INTERVAL |
— |
worker.shutdown_grace_period |
duration | 30s |
SQI_WORKER_SHUTDOWN_GRACE_PERIOD |
— |
worker.allow_root |
bool | false |
SQI_WORKER_ALLOW_ROOT |
— |
worker.keep_failed_sessions |
bool | false |
SQI_WORKER_KEEP_FAILED_SESSIONS |
— |
worker.queue_ids |
[]string | [] |
SQI_WORKER_QUEUE_IDS |
— |
worker.pull_idle_backoff |
duration | 2s |
SQI_WORKER_PULL_IDLE_BACKOFF |
— (deprecated, no effect) |
worker.pull_nack_delay |
duration | 5s |
SQI_WORKER_PULL_NACK_DELAY |
— (deprecated, no effect) |
isolation.required |
bool | false |
SQI_WORKER_ISOLATION_REQUIRED |
— |
isolation.provider |
string | logon_user |
SQI_WORKER_ISOLATION_PROVIDER |
— |
isolation.env_passthrough |
[]string | [] |
SQI_WORKER_ISOLATION_ENV_PASSTHROUGH |
— |
expr.operation_limit |
int | 1000000 |
SQI_WORKER_EXPR_OPERATION_LIMIT |
— |
expr.memory_limit |
int | 20000000 |
SQI_WORKER_EXPR_MEMORY_LIMIT |
— |
expr.assignment_positions |
int | 10000 |
SQI_WORKER_EXPR_ASSIGNMENT_POSITIONS |
— |
expr.assignment_retained_bytes |
int | 20000000 |
SQI_WORKER_EXPR_ASSIGNMENT_RETAINED_BYTES |
— |
expr.let_retained_bytes |
int | 10000000 |
SQI_WORKER_EXPR_LET_RETAINED_BYTES |
— |
capabilities.detect |
[]Detector |
[] |
— (config file only) | — |
capabilities.disable |
[]string |
[] |
SQI_WORKER_CAPABILITIES_DISABLE |
— |
staging.scratch_dir |
string | "" |
— (config file only) | — |
staging.sync_command |
string | "" |
— (config file only) | — |
staging.defaults |
bool | true |
SQI_STAGING_DEFAULTS |
— |
diagnostics.enabled |
bool | true |
SQI_DIAGNOSTICS_ENABLED |
— |
log.level |
string | info |
SQI_WORKER_LOG_LEVEL |
--log-level |
log.format |
string | json |
SQI_WORKER_LOG_FORMAT |
--log-format |
metrics.addr |
string | 127.0.0.1:9091 |
SQI_WORKER_METRICS_ADDR |
— |
metrics.enable_pprof |
bool | false |
SQI_WORKER_METRICS_ENABLE_PPROF |
— |
discovery.enable_mdns |
bool | true |
SQI_WORKER_DISCOVERY_ENABLE_MDNS |
— |
discovery.mdns_timeout |
duration | 5s |
SQI_WORKER_DISCOVERY_MDNS_TIMEOUT |
— |
log_streamer.max_lines_per_chunk |
int | 50 |
SQI_WORKER_LOG_STREAMER_MAX_LINES_PER_CHUNK |
— |
log_streamer.max_bytes_per_chunk |
int | 16384 |
SQI_WORKER_LOG_STREAMER_MAX_BYTES_PER_CHUNK |
— |
log_streamer.flush_interval |
duration | 500ms |
SQI_WORKER_LOG_STREAMER_FLUSH_INTERVAL |
— |
A node in a GPU render farm running Houdini 20.5 with a single NVIDIA GPU:
# /etc/sqi/sqi-worker.yaml
nats:
url: "nats://render-server.studio.local:4222"
discovery:
enable_mdns: false
worker:
name: "gpu-node-04"
farm_id: "studio-main"
data_dir: "/var/lib/sqi-worker"
compute_location: "nas-studio"
capability_tags:
- houdini-20.5
- karma-renderer
- gpu
heartbeat_interval: "15s"
shutdown_grace_period: "10m" # Houdini frames can take several minutes
log:
level: "info"
format: "json"
metrics:
addr: "0.0.0.0:9091" # expose to Prometheus scraperA single worker executes tasks in parallel — the server leases as many tasks as
fit the worker's CPU cores (see CPU reservations in OpenJD
templates). The worker runs whatever
it is leased; concurrency is gated entirely by the server's CPU-core accounting
(amount.worker.vcpu). There is no local task-count cap.
Run separate worker processes when you want distinct worker identities:
independent heartbeats and registrations, different capability sets, or
separate queue assignments — useful for local farm simulation and testing the
scheduler. sqi-worker dials out to the server's NATS (it binds no inbound
ports except the metrics server), so multiple instances coexist on one host as
long as three things differ per instance:
| Setting | Env var | Why it must differ |
|---|---|---|
worker.data_dir |
SQI_WORKER_DATA_DIR |
Holds the persistent worker.id UUID and (when nats.credential_file is left at its default) the worker's nkey seed; a shared dir means a duplicate identity on the server, or two instances fighting over one credential. |
metrics.addr |
SQI_WORKER_METRICS_ADDR |
The local health/metrics HTTP server; a second instance on the same port fails to bind. |
worker.name |
SQI_WORKER_NAME |
Cosmetic only — defaults to the hostname, so instances would otherwise share a label in the web UI. |
Everything else (NATS URL, discovery, capability tags) can be shared or vary
as you like. If broker authentication is on and each instance enrolls with a
join token, each also needs its own token: tokens are single-use by default
(nats.auth.join_token_single_use), so the second instance to redeem a
shared token fails enrollment.
For local development the make run-workers target wires all of this up for
you — see
Running multiple workers locally
in the development guide. A manual example starting three workers:
for i in 1 2 3; do
SQI_WORKER_NAME="worker-$i" \
SQI_WORKER_DATA_DIR="$HOME/.sqi/worker-$i" \
SQI_WORKER_METRICS_ADDR="127.0.0.1:$((9090 + i))" \
sqi-worker start &
doneconfig/sqi-worker.example.yaml— Fully commented example with every option.docs/worker-capabilities.md— Auto-detected capability tags and how to override them.docs/worker-deployment.md— systemd, launchd, and Windows service installation.docs/configuration.md—sqi-serverconfiguration reference.docs/auth.md— the task-isolation model:isolation.manage,run_as_userupdate semantics, and why isolation trades daemon privilege for task privilege.