Custom executors
Implement flexiq.executor.v1 from scratch: the handshake, the frames, capability negotiation, and the rules a stream has to keep.
Implement flexiq.executor.v1 from scratch: the handshake, the frames, capability negotiation, and the rules a stream has to keep.
Every SDK's executor CLI dials the attach protocol over TCP or a Unix socket.
flexiq.executor.v1 is the same protocol as a gRPC stream, and it is there so
an executor can be written in a language no SDK covers — the mirror of what
Clients without an SDK does for
producers. If your language does have an SDK, use
Attached executors instead; this page
is the harder road, taken on purpose.
Get the .proto the same three ways the producer page describes. The service is
flexiq.executor.v1.ExecutorService, and it is a separate package from
flexiq.v1 deliberately: the audiences, credentials and exposure differ, so a
client generated for one stays compilable and reviewable on its own.
The same tarball carries
REMOTE_SDK_CONTRACT.md,
which states this door's obligations normatively — which frames are mandatory,
which are gated on a capability, and what a lease is required on.
You do not have to write one:
github.com/ByteVeda/flexiq/sdks/go/v2/executor
implements this page — the handshake, the session token, the settling frames,
the lease echo, durable steps, reconnect and rotation — behind a handler you
register per task. It advertises all three capabilities below.
sdks/ruby-executor,
the flexiq-executor gem, implements the same handshake, settling frames,
lease echo, reconnect and rotation on the grpc gem, and encodes results with
the producer gem's CBOR codec. It advertises side_channel and lease;
durable steps are not implemented yet, so it does not claim steps.
Cancellation and timeouts are cooperative: a handler calls job.check!.
The grpc gem is not fork-safe, so construct the worker after any fork.
On a push deployment there is nothing to attach to;
without callback settlement the target is an ordinary HTTP handler and needs
no gRPC gem. With FLEXIQ_PUSH_TARGET_SETTLE=grpc it calls Settle over
gRPC, so it does.
Moving off Sidekiq? See From Sidekiq.
An attached executor holds no database credential. It never reads a job row, never writes a result, and never resolves its own namespace, owner or attempt — the scheduler applies every one of those from the dispatch it recorded. That is what makes this a door an application container may dial out through.
It follows that an executor cannot enqueue. A task that fans out to a second
stage has to go back through the producer door as an ordinary client, with a
produce-scoped credential of its own. An execute token opens this package
and nothing else.
Nothing you send names a namespace, an owner, an attempt or a resource cap. Anything an executor could name is something an executor could forge.
Mint a token with the execute scope and send it as authorization: Bearer …
gRPC metadata, exactly like a producer call:
flexiq-server token create --name my-executor --scope executeThe token is checked before the stream is entered, by the same layer that gates
every other RPC on this listener. FLEXIQ_ATTACH_TOKEN is a different
credential for a different listener — the TCP/Unix attach port — and does
nothing here.
gRPC libraries default to 4 MiB in each direction. This door carries what the worker frame protocol allows: job payloads up to 64 MiB, plus envelope, for 68 MiB in total. An executor that leaves the default in place attaches cleanly and then fails on its first large job, which is the worst time to find out.
Attach is one bidirectional stream for the lifetime of a connection.
AttachRequest and AttachResponse are each a oneof over the frames below.
HelloFrame first. It carries executor_id, sdk, version, the
tasks you have handlers for, your slots, protocol_version (currently
1) and the capabilities you implement. It carries no credential — the
bearer token already authorised the call.HelloAckFrame. No job will precede it.protocol_version and both reject a mismatch. A version
is never silently downgraded, and the ack is sent even when the scheduler
is refusing, so both ends can log both numbers.flexiq-attach-session-bin. You need it for Heartbeat.One live stream per executor_id; a second attach under an id already attached
is refused. tasks is also what the scheduler fingerprints your registry from —
it warns when one executor advertises a set no live peer has, which is the
safety net for a worker that imported half its task tree. A mismatch is never a
reason to refuse an attach.
| Arm | Direction | Carries |
|---|---|---|
hello | → scheduler | executor_id, sdk, version, tasks[], slots, protocol_version, capabilities[] |
hello_ack | ← scheduler | scheduler_id, protocol_version, capabilities[] |
job | ← scheduler | id, task name, payload, retry counts, queue, timeout, namespace, disabled middleware, metadata, lease |
job_steps | ← scheduler | A durable-step snapshot, only if you claimed steps |
step_ack | ← scheduler | The answer to one step_commit |
cancel | ← scheduler | A job id to stop running |
shutdown | ← scheduler | Stop; do not reconnect |
success | → scheduler | Job id, result bytes, task name, wall time, lease |
failure | → scheduler | Job id, error, retry counts, wall time, should_retry, timed_out, lease |
cancelled | → scheduler | Job id, task name, wall time, lease |
slept | → scheduler | Job id, wake time, wall time, lease |
progress | → scheduler | Job id, 0–100, lease |
task_log | → scheduler | Job id, level, message, lease |
step_commit | → scheduler | One durable step's outcome, lease |
should_retry is your decision — only the executor can see the exception,
and the scheduler never inspects one. progress and task_log are
fire-and-forget: they carry no reply, never settle a job, and one naming a job
you are not running is dropped.
An unknown arm is skipped, not fatal. A oneof you do not recognise decodes
to no arm; log it once and read the next frame. The stream stays aligned and the
session keeps its in-flight jobs. Do the same in both directions — this is how a
newer scheduler and an older executor stay attached to each other.
Optional behaviour is negotiated, never versioned. hello_ack.capabilities is
what the scheduler will do on your behalf; hello.capabilities is what you
implement. Send no frame for a behaviour that was not advertised.
| Capability | Means |
|---|---|
side_channel | The scheduler applies your progress and task_log frames to storage |
steps | It applies your step_commit frames, and will send you job_steps |
lease | It sends a lease on every dispatch and checks it on every frame about one |
Adding a capability never bumps protocol_version, which is the whole point:
scheduler and executors do not have to upgrade together. Missing capabilities
degrade quietly — the calls become no-ops — with one exception. steps fails
rather than degrades: a durable step that silently did not commit is a step
that will re-run a charge.
lease is an opaque byte string the scheduler mints when it wins a job's
execution claim. It rides the job frame, and you echo it back on every frame
that settles or advances that attempt: success, failure, cancelled,
slept, progress, task_log and step_commit.
Never inspect it, never construct one, never reuse one across attempts.
hello and heartbeat carry none — they belong to the connection, not to any
job. A frame that should carry a lease and does not is dropped, and what it was
reporting is a job that ran without its result being recorded.
cancel() promises, per topologycancel above is this door's frame, and what it does for you follows the same
rule an attached executor gets — the scheduler sends it and you stop. That is
not true everywhere FlexiQ dispatches a job, and the difference is worth
knowing before you build against one topology and assume it generalizes:
| Topology | What cancel() promises |
|---|---|
| Native | The handler observes the storage flag and stops. |
| Attach | Within about a second the scheduler sends a cancel frame; the executor stops. |
| Push, in the request | The request is abandoned and the attempt settles Cancelled. The target sees only its connection close; its side effects still happen and its answer is fenced out. |
Push, accepted (202) | The attempt settles Cancelled. The target's next ExtendLease, ReportProgress, WriteTaskLog or Settle is refused with reason JOB_CANCELLED by the replica that dispatched it, while it still remembers the ending — a target that polls stops then; one that never does runs to the end. Any FAILED_PRECONDITION here means stop. |
An executor built against this page — attach, over flexiq.executor.v1 or the
socket protocol either one — is the attach row: cancellation stops the work,
because there is a connection to send it down. Push dispatch
has no such connection, so there cancel stops the result and tells a target
that asks. Moving a task from one topology to the other changes which promise
it gets.
A cancel reaches the scheduler through storage, so it does not matter where it
was made — the producer door's CancelJob, the dashboard, or an SDK sharing
the database all work the same way.
Attach is not the only way into this door. A push target
never attaches — the scheduler dials out to it — but a job that cannot finish
inside the platform's request deadline still has to report somehow, so four
unary RPCs exist for exactly that:
| RPC | What it does |
|---|---|
Settle | Reports the outcome of a dispatch the target answered 202 Accepted to. Single-use for the attempt it names. |
ExtendLease | Asks for longer before the attempt is considered lost. Repeatable; clamped to an hour per call. |
ReportProgress | The progress frame, off the stream. |
WriteTaskLog | The task_log frame, off the stream. |
They carry the same frames the stream does — a SuccessFrame reported over
Settle and one sent over Attach say the same thing — and they are fenced on
the same lease. Settle has no slept arm: a push dispatch has no step
session to resume.
On a push deployment the door serves only these four. Attach and Heartbeat
answer FAILED_PRECONDITION there, because there is nothing to attach to —
the service surface stays constant and the refusal says which deployment you
reached, rather than making "does this RPC exist" a configuration question.
Heartbeat is unary and deliberately off the dispatch stream: a busy stream and
a dead peer would otherwise be indistinguishable. It carries free_slots and
the session token the Attach response returned — not your executor_id,
which is a name you picked and could therefore be another executor's.
Streams are bounded on purpose, FLEXIQ_GRPC_EXECUTOR_STREAM_MAX_AGE seconds,
30 minutes by default. A gRPC stream cannot be load-balanced once it has
started, so one that never ended would pin every executor to whichever replica
it first reached. Before ending one, the scheduler stops matching new work to it
and waits for what it already holds, so a rotation never costs a job in flight.
A clean stream end means reconnect. A shutdown frame means stop. Both are
ordinary outcomes, not failures — but log the difference, because an executor
that treats a rotation as an error will reconnect anyway and leave you an
incident to chase.
A job frame's payload is the tagged wire envelope, and your success
frame's result is one too. The tag table, the [args, kwargs] call body, the
bare-value result body and the conformance vectors are all on
Clients without an SDK.
Decode what you are given; do not re-encode it.
| Cost | Mitigation |
|---|---|
| Everything an SDK's executor CLI does — reconnect, backoff, frame framing, capability handling — is yours to write | Take TCP/Unix attach with an SDK if one covers your language |
| No CPU-parallelism story: a remote executor is a network hop from the scheduler | Colocate it with the scheduler when throughput matters more than isolation |
| You cannot enqueue from here | Hold a second, produce-scoped credential and use the producer door |
A missed lease echo loses a job's result silently | Echo it structurally — attach it where you build the frame, not per call site |
| TLS is off by default | Set FLEXIQ_GRPC_TLS_CERT/_TLS_KEY to terminate it in process (TLS and mTLS), or put a sidecar proxy or service mesh in front |