Attached Executors
Run task bodies in your app container while a separate scheduler holds the database credentials.
Run task bodies in your app container while a separate scheduler holds the database credentials.
An attached executor splits a worker in two. The scheduler — flexiq-server
— holds the database connection, claims jobs, and owns retries, the dead-letter
queue and retention. Your app container runs flexiq executor, which dials the
scheduler, announces the tasks it can run, and executes whatever it is sent.
The app image needs no database credentials and no inbound port.
An in-process worker stays the default and the simplest deployment. Reach for an executor when the app image is large enough that running a second copy of it as a worker is the expensive part — an interpreter or JVM plus model weights resident twice, and a multi-gigabyte image pull on every scale-up.
For the deployment shape — a Compose stack, a Kubernetes sidecar over a shared Unix socket, and the security requirements that go with the attach port — see Deployment.
flexiq-server is configured entirely through the environment — there are no
flags. At minimum it needs a DSN and an attach address:
FLEXIQ_DSN=postgres://user:pass@db/flexiq \
FLEXIQ_LISTEN=0.0.0.0:7777 \
FLEXIQ_ATTACH_TOKEN=$(openssl rand -hex 32) \
flexiq-serverThe scheduler starts lazily, on the first attach. With nothing attached it would claim jobs no executor advertises, and each would fail retryably once the placement timeout elapsed — a retry storm against an idle deployment.
FLEXIQ_ATTACH=scheduler:7777 \
FLEXIQ_ATTACH_TOKEN=... \
flexiq executor --app myapp:queue --slots 4FLEXIQ_ATTACH=scheduler:7777 \
FLEXIQ_ATTACH_TOKEN=... \
flexiq executor ./app.js --slots 4FLEXIQ_ATTACH=scheduler:7777 \
FLEXIQ_ATTACH_TOKEN=... \
java -cp app.jar org.byteveda.flexiq.cli.Cli executor --slots 4Handlers are discovered from the classpath through META-INF/services, so no
application main has to run to register them.
--slots is how many jobs run at once; the scheduler dispatches a task name
only to executors that advertised it, so a handler missing from an executor is a
job that never reaches it.
Java's --serializer (or FLEXIQ_SERIALIZER) picks the payload wire format:
json (default) or cbor for a task produced or consumed by another SDK — it
has no application main to set this the way a Python or Node executor's
imported module can. cbor needs
com.fasterxml.jackson.dataformat:jackson-dataformat-cbor on the executor's
runtime classpath, the same optional dependency CborSerializer needs
anywhere else it's used.
A job an executor is already running is never sent to it a second time. Every
frame an executor sends back names a job by id and nothing else, so two attempts
of one job on one connection would be indistinguishable: a step could not be
fenced to the attempt that committed it, and one of the two results would be
dropped. This is reachable in normal operation — the reaper cannot tell a slow
attempt from a dead one, so it reclaims the job while the executor is still
running it. The next attempt then goes to another executor, or waits for the
first to report and fails retryably with was not dispatched: every executor with a free slot is already running an earlier attempt of it if the placement timeout
elapses first.
Across executors, the lease is what separates the attempts. The scheduler mints one when it wins a job's execution claim, sends it on the job frame, and requires it back on every frame that settles or advances the attempt. A frame carrying a lease the job is no longer dispatched under is refused and logged at error level — the frame is dropped, but what it reports is a job that ran twice, so it is an incident rather than housekeeping. The case this exists for is the one nothing else catches: requeuing a stuck job from the dashboard leaves both its owner and its attempt number alone, so without the lease the stalled executor's late result is indistinguishable from the live one's.
An executor built before the lease existed still attaches and still runs jobs; it does not negotiate the capability, is dispatched no lease, and keeps the weaker fence. Nothing about the rollout needs coordinating.
A secret in argv shows up in ps output and shell history. Every SDK reads
FLEXIQ_ATTACH_TOKEN from the environment and offers no flag for it.
The attach port dispatches code, so the defaults are strict:
FLEXIQ_LISTEN refuses to start without
FLEXIQ_ATTACH_TOKEN.hello must arrive before any other frame; an unauthenticated socket is
dropped rather than queued, and never receives an acknowledgement.unix:/run/flexiq.sock) for a same-pod sidecar; it needs
no token because the filesystem permissions are the boundary.The token is a bearer credential, not transport security. It proves who is attaching; it does not encrypt or integrity-protect the frames, and those frames carry task payloads on a port that dispatches code. On anything but loopback or a Unix socket, terminate mTLS in a proxy in front of the listener and treat the token as the second factor.
flexiq-server does not terminate TLS itself. Setting
FLEXIQ_LISTEN_TLS_CERT or FLEXIQ_LISTEN_TLS_KEY fails at startup rather
than being ignored, so a deployment cannot come up believing it is encrypted
when it is not.
A task body behaves the same as it does on an in-process worker. Progress, log lines and published partials all work — the executor has no storage, so it reports them to the scheduler, which applies them:
@queue.task()
def resize(path: str) -> str:
current_job.update_progress(50)
current_job.log("halfway")
current_job.publish({"stage": "halfway"})
return "done"queue.task("resize", async (path: string) => {
const job = currentJob();
job?.setProgress(50);
job?.log("halfway");
job?.publish({ stage: "halfway" });
return "done";
});@TaskHandler("resize")
public String resize(String path) {
JobContext job = JobContext.current();
job.setProgress(50);
job.log("halfway");
job.publish(Map.of("stage", "halfway"));
return "done";
}Middleware disabled from the dashboard is honoured too. The executor cannot read settings, so the scheduler resolves the list and attaches it to each dispatch — a toggle still takes effect on the next job, with nothing to restart.
Durable steps work on an attached executor, and they cost a round trip each.
The executor has no database, so neither half of a step happens locally: the steps a job already committed ride in on its dispatch, and every new one crosses to the scheduler, which performs the write under the execution claim it holds. The executor blocks until that write is acknowledged. It has to — a step whose commit was never confirmed may or may not have landed, and carrying on would re-run it on the next attempt with the side effect already applied.
That makes a step a checkpoint, not a loop body. An in-process worker pays one local write per step; an attached one pays a network round trip. Checkpoint around the things you would hate to repeat — a charge, a provisioning call, an email — not around every iteration.
Two failures are worth knowing by name. A commit the scheduler could not apply
because its backend was unavailable fails the attempt retryably, and the
replay re-runs the step under the same downstream idempotency key, which is what
makes it safe. A scheduler that never advertised the steps capability — no
database configured for it, or a backend without a step store — refuses the
step outright rather than running it un-memoized.
Anything that needs the database directly. An enqueue, a workflow submission or a queue inspection raises rather than silently doing nothing, because an enqueue that quietly vanished would be worse than one that failed.
A handful of job fields also arrive as zeros and nulls, because a dispatch frame
carries what running the task needs rather than the whole row: created_at,
scheduled_at, priority, unique_key and notes. metadata is the
exception — middleware reads it, so it rides the frame. A task that needs the
rest wants a worker, not an executor.
The scheduler and its executors upgrade independently. The handshake carries a capability list rather than a version both sides must match, so a newer executor attached to an older scheduler never sends a frame that scheduler could not parse — it degrades instead.
Concretely: a scheduler that does not advertise side_channel gets no progress
or task-log frames, and those calls become no-ops (logged once). Everything else
— dispatch, results, retries, cancellation — is unaffected. The executor logs
which capabilities it negotiated at attach.
steps is the one capability that fails rather than degrades. Progress that
goes nowhere costs a dashboard bar; a step that goes nowhere re-runs a charge.
The failure is retryable, so a fleet mid-rollout still makes progress: the next
attempt may land on a worker or an executor that can commit.
lease degrades symmetrically, which is the point: a scheduler dispatches a
lease only to an executor that said it would echo one, so it never requires
a value the peer will not send. An executor without it keeps the fence it always
had — the claim's owner, the attempt, and the claim's epoch.
Skew in the other direction is covered too. A frame type the receiving side has never heard of is logged once and skipped rather than treated as a corrupt stream: a header declares its own payload length, so the reader stays aligned on the next frame. That holds both ways, so neither an older scheduler nor an older executor drops a live session — and its in-flight jobs — over a frame it did not need.
| Cost | Mitigation |
|---|---|
| CPU-heavy tasks compete with request latency in the app container | Cap --slots; or run a second replica set from the same image with the HTTP server off |
| A network hop per job | Use a Unix socket for same-pod sidecars; attach is a poor fit for microsecond-scale tasks |
| A round trip per durable step | Checkpoint around what you would hate to repeat, not around every iteration |
| The attach port dispatches code | A Unix socket where the peer is same-pod; off loopback, proxy-terminated mTLS and the token, never the token alone |