The admin door
flexiq.admin.v1 — pause queues, work the dead-letter queue, list workers, manage periodic tasks and overrides over gRPC or JSON, behind scopes a producer token never carries.
flexiq.admin.v1 — pause queues, work the dead-letter queue, list workers, manage periodic tasks and overrides over gRPC or JSON, behind scopes a producer token never carries.
Everything an operator does from the dashboard — pause a queue, replay a dead
letter, see which workers are alive, define a schedule, cap a task's rate — is
also a call on the gRPC listener: flexiq.admin.v1.AdminService, and the same
calls as JSON under /v1/admin/. It is how a script, a runbook or
fq operates a deployment without the dashboard and
without an SDK.
It is not the producer door with more methods. It is its own package behind its own scopes, because an operator surface and a producer surface must not share a credential:
| Scope | Reaches |
|---|---|
inspect | Every read-only method: ListQueues, GetThroughput, ListDeadLetters, GetDeadLetter, ListWorkers, ListPeriodicTasks, GetPeriodicTask, ListOverrides, GetNamespaceQuota, ListAuditRecords |
admin | Every other method, except the token methods |
tokens | CreateToken, GetToken, ListTokens, RevokeToken, reads included |
The split is not a list someone keeps: a method that declares
idempotency_level = NO_SIDE_EFFECTS needs inspect, and anything else needs
admin. Over JSON that is the verb — GET is inspect, POST is admin.
The token methods are the one exception: everything under
/v1/admin/tokens needs tokens, which neither of the others implies.
Neither implies the other, so an operator's token carries both:
export FLEXIQ_TOKEN=$(flexiq-server token create --name ops --scope inspect --scope admin)A produce token reaches none of it, and an inspect token asked to change
something is refused PERMISSION_DENIED with the reason SCOPE_DENIED and
scope: admin in the metadata. See API tokens.
Both scopes can be narrowed to some queues or tasks, for an operator who runs
one team's queues. The listings then take a queue or task_name filter the
token must name. Narrowing an operator
lists what each method checks.
Every call acts on the namespace the token was minted for, and on nothing else.
A dead letter, a periodic task or a queue in another namespace answers exactly
as one that does not exist — NOT_FOUND, with no way to tell the two apart —
and a listing never shows another tenant's queue names, workers or schedules.
That took storage changes, not only a check at the door: before this service, a queue pause, the worker registry, a dead-letter purge and task overrides were one set for the whole database. They are per-namespace now, for the dashboards and the SDKs as much as for this door. A pause set before upgrading is the default namespace's afterwards; see the changelog.
curl -s -X POST -H "authorization: Bearer $FLEXIQ_TOKEN" \
localhost:50051/v1/admin/queues/emails:pause{"queue":{"cancelled":"0","completed":"0","dead":"0","failed":"0","name":"emails","paused":true,"pending":"0","running":"0"}}A pause stops dispatch from the queue in this namespace; jobs already running
finish. :resume lifts it. Both answer with the queue as the call left it, so
calling either twice is safe. A queue with no jobs yet may be paused — the pause
holds once jobs arrive.
GET /v1/admin/queues lists every queue the namespace has a job, a pause or an
override for, with the same six counters QueueStats carries.
GET /v1/admin/throughput?window=600s counts the jobs per queue that reached a
terminal status inside the window — five minutes when unset, at most a day.
It answers counts and the window it applied, not a rate: divide by the window.
| Call | Does |
|---|---|
GET /v1/admin/deadLetters | Pages newest first, pageSize and pageToken as ListJobs takes them. No payloads |
GET /v1/admin/deadLetters/{id}?includePayload=true | One entry, with its envelope when asked |
POST /v1/admin/deadLetters/{id}:replay | Enqueues it again as a fresh job and answers with that job; the entry is gone |
POST /v1/admin/deadLetters/{id}:delete | Deletes it. A second delete is NOT_FOUND |
POST /v1/admin/deadLetters:purge | Deletes in bulk: {"failedBefore": "…"}, {"taskName": "…"}, or {} for every entry |
A replayed job keeps the dead job's queue, task, body and priority, and none of its uniqueness or dependencies — the same thing a dashboard replay does. It is not idempotent: each successful call makes a job.
GET /v1/admin/workers lists the registered workers in this namespace, with
their queues, status, concurrency and last heartbeat. A worker registered by a
release before this one reads as the default namespace's until it restarts.
POST /v1/admin/workers/{id}:drain asks one to stop gracefully — the stop a
SIGTERM gives it: it claims nothing new, finishes what it holds, and
unregisters. The worker reads the request on its next heartbeat, a few seconds
later, so the call answers with the worker DRAINING and the drain itself
shows as the worker leaving the listing. A worker in another namespace, or one
already gone, is WORKER_NOT_FOUND.
A drain reaches registered workers — the rows this listing shows. An executor attached to the scheduler, or a push target, has no row of its own and is not drained this way.
POST /v1/admin/periodicTasks creates a schedule, or replaces one's
definition:
curl -s -X POST -H "authorization: Bearer $FLEXIQ_TOKEN" \
-H 'content-type: application/json' localhost:50051/v1/admin/periodicTasks \
-d '{"name": "nightly-report", "taskName": "build_report", "cron": "0 0 2 * * *",
"timezone": "Europe/Berlin", "structured": {"args": ["daily"]}}'The cron expression has six fields, seconds first. Its arguments are either
arm Enqueue takes — raw or structured — and none is a call with no
arguments. A bad expression or an unknown timezone is refused before anything
is written.
A replace is the same conditional write a worker makes when it declares a schedule in code: it never resumes a task an operator paused, never forgets when it last ran, and moves its next run only if the expression or the timezone changed. The flip side: a task also declared in a worker's code is declared again when that worker starts, and its code wins every field but the pause.
:pause and :resume stop and restart firing. :trigger enqueues the job the
schedule would, now, whether or not it is paused, and leaves the next run
alone. :delete removes the schedule; jobs it already fired are untouched.
An override replaces a task's or a queue's declared rate limit, concurrency cap, retries, backoff, timeout or priority — the knobs the dashboard's Tasks and Queues pages edit, in the same stored documents.
curl -s -X POST -H "authorization: Bearer $FLEXIQ_TOKEN" \
-H 'content-type: application/json' localhost:50051/v1/admin/tasks/send_email/override \
-d '{"rateLimit": "100/m", "timeout": "30s"}'SetTaskOverride replaces the whole override rather than merging — a field
left out is no longer overridden — and :clear removes it. A rate is
<count>/<s|m|h> with a count of at least one; a timeout is whole seconds. A
queue's override has no paused: PauseQueue is the one way to pause a queue.
Workers read overrides when they start. A change made here applies to every
worker that starts afterwards and to no worker already running — restart the
fleet to apply it now. flexiq-server's own scheduler counts as a worker here:
under attach or push it applies the backoff, rate limits and concurrency caps
it finds when it starts.
The server's scheduler applies the fields a scheduler consumes: retryBackoff,
rateLimit and maxConcurrent on a task, and rateLimit and maxConcurrent
on a queue. timeout, priority and a task's pause are applied where jobs are
enqueued, by an SDK, so an override of them does not reach a job enqueued
through this door.
GET /v1/admin/quota reads the namespace's quota,
POST /v1/admin/quota sets it and POST /v1/admin/quota:clear lifts it. The
namespace is always the token's; the request never names one.
curl -s -X POST -H "authorization: Bearer $FLEXIQ_TOKEN" \
-H 'content-type: application/json' localhost:50051/v1/admin/quota \
-d '{"maxPending": "10000", "onExcess": "QUOTA_OVERFLOW_DROP", "enqueueRate": "500/s"}'SetNamespaceQuota replaces the whole quota, like an override, and a quota
with every field unset is the same as :clear. 64-bit limits are strings over
JSON.
GET /v1/admin/auditRecords pages the namespace's
audit trail, newest first: a record for each target of
every token-authorised write — this door's included — naming the token, the
RPC, the target and the outcome. It filters by tokenId, targetKind, target, since
and until, and pages with pageSize and pageToken.
A token holding the tokens scope manages the namespace's
API tokens without the dashboard — what a
provisioning tool needs to mint a credential per service and revoke it on
teardown.
| RPC | JSON | Does |
|---|---|---|
CreateToken | POST /v1/admin/tokens | Mints a token; the response carries its secret, once |
GetToken | GET /v1/admin/tokens/{token_id} | One token, revoked or expired included |
ListTokens | GET /v1/admin/tokens | Every token in the namespace, newest first |
RevokeToken | POST /v1/admin/tokens/{token_id}:revoke | Revokes it; revoking twice answers the same token |
A token can never mint one wider than itself:
produce:queue=emails-* covers
produce:queue=emails-eu, not produce or read. Otherwise the call is
SCOPE_DENIED, naming the scope.INVALID_REQUEST, naming the latest expiry allowed — never
quietly shortened. The default of 90 days is refused by a caller with less
than that left, so pass expireDays.TOKEN_NOT_FOUND.From a shell, fq tokens calls these four methods.
No response but CreateToken's carries the secret, and none carries its
digest. admin reaches none of these methods.
The Go client's
admin
subpackage, github.com/ByteVeda/flexiq/sdks/go/v2/admin, wraps the queue,
override, periodic-task, quota and token methods. It dials from the producer
client's own options — the token, TLS, the 4 MiB message cap — and fails with
the same *flexiq.Error, so a missing resource is a reason to branch on:
client, err := admin.New("queue.internal:50051", flexiq.WithToken(os.Getenv("FLEXIQ_ADMIN_TOKEN")))
if err != nil {
return err
}
defer client.Close()
_, err = client.PutPeriodicTask(ctx, admin.PeriodicTaskSpec{
Name: "nightly-report",
Task: "reports.build",
Cron: "0 0 2 * * *",
Args: []any{map[string]any{"kind": "daily"}},
})
if err != nil {
return err
}
task, err := client.GetPeriodicTask(ctx, "nightly-report", admin.GetPeriodicTaskOptions{IncludePayload: true})
if errors.Is(err, flexiq.ReasonPeriodicTaskNotFound) {
return nil // gone: deleted, or never in this token's namespace
} else if err != nil {
return err
}
call, err := task.DecodePayload() // the arguments each firing passes
if err != nil {
return err
}
log.Printf("nightly-report fires with %v", call.Args)A periodic task's arguments are encoded exactly as an enqueue's, so the
payload read back is byte-for-byte what was sent — the comparison a
declarative tool makes to detect drift. A queue, a queue override and a quota
have no not-found: GetQueue and GetQueueOverride answer a found flag, and
an unset quota limit is a nil pointer.
CreateToken is never safe to retry blindly: the server takes no idempotency
key, so a call that lost its answer may have minted a token whose secret
nobody holds. Look for it with ListTokens and revoke it instead.
Every refusal carries a google.rpc.ErrorInfo reason from the same closed list
the producer door uses, plus four of this door's own:
| Reason | Code | When |
|---|---|---|
DEAD_LETTER_NOT_FOUND | NOT_FOUND | No such entry in this namespace |
PERIODIC_TASK_NOT_FOUND | NOT_FOUND | No such periodic task in this namespace |
WORKER_NOT_FOUND | NOT_FOUND | No such registered worker in this namespace |
TOKEN_NOT_FOUND | NOT_FOUND | No such API token in this namespace |
INVALID_REQUEST | INVALID_ARGUMENT | An empty name, a bad cron expression, timezone, rate or window, or a token that would outlive its minter |
SCOPE_DENIED | PERMISSION_DENIED | The token lacks inspect, admin or tokens, or its grants do not reach the queue or task named, or do not cover a grant it tried to mint or revoke; the metadata says which |