The playground¶
Three things an instance gives a document that needs something to happen instead of a service to call: a node that generates data and behaviour in the run itself, a sensor that makes the run wait for that data the way it waits for a queue, and a handful of HTTP routes the instance serves to itself. All of them are always on, all of them are ours, and none of them needs a network.
Which one to reach for¶
The node, playground.generate, is for a document that needs something to happen. A
fan-out that needs a list to walk, a retry that needs a step that fails twice, a gate that
needs a shape that drifts, a storage example that needs a payload over the threshold. No
connection, no URL, no network, and it works at the start of a flow, in the middle of one, or
as its last step.
The sensor, playground.arrive, is for a document that needs to wait for something. A
readiness poke, a batch that turns up after a while, a step that has to be seen parking before
it is seen working. It is the node's generation behind a sensor's shape, so a document can
watch a pipeline tick without a broker, an endpoint or a file to poke.
The routes are for a document that is teaching HTTP itself. A request with headers and a query, a status a client has to handle, a redirect, a delay against a timeout, a body that arrives in pieces. Those documents want a real HTTP hop, and now they have one they own.
A document that wants rows should reach for the node. A document that wants to be seen waiting
should reach for the sensor. A document that wants to show http.request doing something
should reach for the routes.
The routes generate nothing, and that is deliberate rather than an omission. A field map is
Faker, Faker lives in the block family that contributes playground.generate, and
dirigent-server does not depend on a block package: blocks are a plugin surface, contributed
through pluginkit and run on workers, and a server-only install in a split deployment must not
have to carry the built-in block set to serve an API. So the split is along what each surface
is for -- the routes hand a consumer bytes arriving over time, and the blocks put generated
data into a pipeline, which is where the field map belongs.
The node¶
One block, playground.generate, contributed by the block-base family. It takes an
optional input and produces an output, so it is an ordinary node in the graph.
steps:
people:
block: playground.generate
config:
rows: 5
seed: 42
fields:
who: name
where: city
when: date_this_decade
{
"records": [{"who": "Brian Yang", "where": "Lake Joshuabury", "when": "2024-04-21"}],
"rows": 5, "seed": 42, "locale": "en_US",
"fields": {"who": "name", "where": "city", "when": "date_this_decade"},
"drift": "none", "page": null, "size": null, "pages": null,
"delay_ms": 0, "attempt": 1, "fail_until": 0, "fail_as": "transient",
"payload": null, "payload_bytes": null, "input_rows": null
}
Every knob comes back on the output, resolved: what the step actually ran with, after defaults and after a seed was drawn. A reader looking at a middle node in the UI can see which knobs produced what they are looking at.
The knobs¶
| Knob | Default | What it does |
|---|---|---|
fields |
name, email, city |
Which Faker provider fills each output field. |
rows |
1 |
How many records exist. Ignored when an input is supplied. |
locale |
en_US |
The Faker locale the providers generate in. |
seed |
drawn | Makes the records reproducible; the output always says which seed was used. |
drift |
none |
Emit a deliberately wrong shape: strings, missing or extra. |
page, size |
unset | Hand back one page of the records, and say how many pages there are. |
payload |
unset | Filler of a chosen size, as 32kb, beside the records. |
delay |
0s |
Take that long before answering. At most 30 seconds. |
fail_until |
0 |
Fail that many attempts before succeeding. |
fail_as |
transient |
Which class those failures carry: transient, rejected or unknown. |
input |
unset | A value from upstream to work from. |
Every one of them defaults to something quiet, so a step with no config at all generates one row and answers at once, and a document only sets the knob it is teaching.
All of Faker, through the field map¶
fields maps an output field name to the Faker provider that fills it. Every provider the
installed Faker offers is reachable, and nothing has to be added here for a new one to work:
fields:
who: name
where: city
what: catch_phrase
# A provider that takes arguments is written as an object.
score:
provider: pyint
args:
min_value: 0
max_value: 100
Finding a provider. A reader cannot guess 280 names, and a list pasted into this page would rot. Ask the installed Faker instead, which is the one the node uses:
$ uv run faker # every provider, with a sample of each
$ uv run faker -l no_NO # the same, for one locale
$ uv run faker pyint 1 6 # one provider, with its arguments
What a provider name may be. A field map is a string from a document selecting an
attribute on a library object, so it is bounded: the node builds the set of names the
installed Faker actually offers, from the provider objects themselves, and refuses anything
outside it. Nothing beginning with an underscore is ever in the set, and Faker's own plumbing
-- seed, seed_instance, add_provider, format and the rest -- is excluded by name. An
unknown provider is a rejected failure that names what was not found.
A Decimal comes back as a string, because it keeps its precision. A field a schema
reads as a number wants pyfloat or pyint.
A seed, always¶
Anything that varies takes a seed, and the answer says which seed produced it. Give one and the records are identical on every run and every machine, which is what lets an example assert on them. Give none and the node draws one and reports it, so a surprising answer can still be reproduced by sending that number back.
The payload filler is a fixed repeating pattern rather than generated, so a payload of a
given size is the same bytes every time and has no seed to report.
What an input does¶
input is the field every transform names for the same idea, and it means the same thing
here, except that it is optional: the node is often a document's first step and has only its
knobs to work from.
input |
fields named |
What happens |
|---|---|---|
| absent | either | The knobs generate rows records. |
| a list | no | Each element is a record, unchanged. |
| a list | yes | Each element gains the generated fields. |
| an object or a scalar | either | One record. A scalar arrives under value. |
A supplied input decides how many records there are, and rows is ignored. This is the
case a reader hits first: rows: 10 against a list of three gives three records, and the
output reports rows: 3 and input_rows: 3 so the truth is on the record rather than in
this page. drift, page, size and payload all apply to a supplied input exactly as they
apply to a generated one.
Drift¶
drift makes the shape wrong on purpose, so a schema gate can be seen catching it. Each one
breaks a different kind of rule:
drift |
What changes | What refuses it |
|---|---|---|
strings |
Every number and boolean becomes a string. | A schema with "type": "number". |
missing |
The field map's first field is left out. | A schema with required. |
extra |
A field called drifted is added. |
A schema with additionalProperties: false. |
Failing on purpose¶
fail_until fails that many attempts before succeeding. It reads the attempt number the
engine hands the step, so a retry example really fails and really recovers rather than
describing a recovery it never reaches. fail_as says what kind of failure those are, and
the kind is the whole of what the engine reads when it decides whether to spend another
attempt on the step:
fail_as |
What the engine does with it | What it stands in for |
|---|---|---|
transient (default) |
Retried while the step's budget lasts. | A network error, a 5xx, a timeout. |
rejected |
Never retried: the step ends on its first failure, whatever max_attempts says. |
A validation error, auth, a 4xx. |
unknown |
Retried while the budget lasts. | A failure the block declines to classify, such as a command that exited 1. |
steps:
refused:
block: playground.generate
retry:
max_attempts: 3
config:
fail_until: 9
# One attempt. The other two are never spent, because no later attempt would
# change a refusal into an acceptance.
fail_as: rejected
Those are the three classes the engine distinguishes and all three are the block's to raise,
so the contrast that used to need a live endpoint answering 404 is an offline document now:
examples/patterns/retry-only-transient.yaml
puts one policy on all three and reads the attempt counts back.
Both knobs come back on the output, so an attempt says which window it was inside and what
class its failures carried. fail_as on its own does nothing: a step with fail_until: 0
never fails, whatever class it names.
The three positions¶
The node is designed to sit anywhere in a document, and
examples/playground/
shows all three:
- At the start, with no
inputand no upstream: the lead case, and the reasoninputis optional at all. - In the middle, taking a value from an earlier step and handing one to a later one.
- At the end, as the document's last step. It requires nothing downstream, and its output is an account of what happened: the records, the seed, and every knob.
"Do it once, do it ten times"¶
rows: 10 is one step run whose one output holds ten records. Ten separate units of work is
a different thing, and the engine already has a word for it: for_each on the step, which
gives each element its own attempt, its own retry budget and its own row in the grid. Over a
parameter the grid is fixed when the run is created; over an earlier node's
${steps.<name>.output.records} it is written once that node has answered.
steps:
# One unit of work, ten records in its output.
batch:
block: playground.generate
config:
rows: 10
# Ten units of work, each generating its own records.
one_each:
block: playground.generate
for_each: ${params.stations}
config:
rows: 1
fields:
station: city
So there is no mode knob and there will not be one. "Do it once" is rows: 1, "do it ten
times" is for_each, and a knob that renamed either would put a block-local spelling beside
an engine semantic that is uniform across every block -- the same reason poll, deadline
and retry live on the step and never inside a block's config.
Streaming through the DAG is a third thing again, and it is not a knob on this node: a step is the unit, and a step's unit is its whole output. The sensor below is the shape that works on today's engine, and it is where a document that wants to watch a pipeline tick should go.
The sensor¶
One block, playground.arrive, contributed by the same family. It is a sensor, so the
engine pokes it on a cadence instead of running it once: each poke is one cheap, read-only
question, the run holds no worker slot between pokes, and poll, deadline and on_timeout
are the step's own engine semantics exactly as they are for storage.exists or
kafka.consume.
The question it answers is "has the batch arrived yet". It answers no for as many pokes -- or for as long -- as the document asks, and then one poke generates the batch and hands it downstream.
steps:
wait_for_batch:
block: playground.arrive
poll: 1s
deadline: 1m
config:
after_pokes: 2
rows: 4
seed: 42
fields:
station: city
reading: pyfloat
{
"messages": [
{"offset": 0, "value": {"station": "East Donald", "reading": 409.37}, "timestamp": "..."}
],
"count": 4, "next_offset": 4, "pokes": 3, "waited_ms": 2038,
"seed": 42, "locale": "en_US",
"fields": {"station": "city", "reading": "pyfloat"},
"drift": "none", "payload": null, "payload_bytes": null
}
The knobs¶
The six generation knobs are the node's, and they mean the same thing here: fields,
rows, locale, seed, drift and payload. rows is how many messages the batch holds.
A field map moved from one block to the other is unchanged, and the same seed gives the same
records on both.
Two knobs are the sensor's own, and they decide only when the parking stops:
| Knob | Default | What it does |
|---|---|---|
after_pokes |
1 |
How many pokes park before the batch arrives. 0 lets the first poke carry it. |
after |
0s |
How long the wait lasts, measured from when the attempt started, or under a sensor source from when the source began waiting. |
Both are floors, so the batch arrives on the first poke that has cleared them both. Set
neither and the default is one park and then the batch, which is the smallest thing that shows
a step waiting. The step's deadline still ends the wait either way; the sensor's own defaults
are a 2s poll and a 10 minute deadline.
The sensor does not fail on purpose, and fail_until and fail_as are the node's alone.
A sensor's word for "nothing yet" is NotYet, which is not a failure, and in a document the
two would read as one thing: a poke that parks and a poke that fails both leave the step
unsettled. They are not one thing to the engine -- a park keeps the attempt and a failure ends
it -- and the difference shows up in the cursor, which lives on the attempt row: in a step, a failed poke that earns a retry starts the next attempt with no cursor and parks the
whole wait again from zero. That is a real lesson about sensors, and it is not the lesson about retry classification;
a document that wants the classes wants the node, which is one step and one attempt number.
What one poke answers¶
A poke answers one of two things, and neither is a failure:
NotYet, while the wait lasts. It carries the poke count forward in the cursor, the remaining time asnext_poll_inwhenafteris what is holding it back, a progress fraction read off whichever floor is furthest from being cleared, and a message --poke 1 of 3, or1400ms of the wait left-- that shows on the waiting attempt.- The batch, on the poke that clears both floors. Nothing is generated before that poke, so a park costs a comparison and not a field map.
The poke count lives in the cursor and nowhere else, which is what makes the wait durable across a worker restart. So does the offset the batch will start at. A cursor is at-least-once: a worker that dies between a park and the transaction that commits it leaves the older count for the next poke to read, so the sensor parks one poke longer rather than arriving early.
The consume shape¶
The output is shaped the way a queue consumer's is on purpose. messages with an offset and
a value each, and count beside them, is what kafka.consume hands downstream, so a
document written against the playground reads almost unchanged against a real topic once there
is one to point it at.
What it does not have is what the playground has nothing to put in: no topic, no partition, no
key, no headers, and no broker offsets to commit. timestamp is when the batch arrived, and
next_offset is where the next batch starts.
As a source, it is a stream¶
Named by a sensor source, playground.arrive is a source that
never runs dry: a worker pokes it outside any run, each batch that arrives becomes one run, and
the next wait starts its offsets where this batch's ended. Three runs of three rows carry
offsets 0 to 8 between them, with nothing to connect to, so the run list filling and the graph
lighting up batch after batch is what consuming a topic looks like, offline:
examples/sources/sensor-arrivals.yaml.
A readiness wait, offline¶
http.ready needs a live endpoint to poke, so a document teaching a readiness wait could not
be an offline example and could not be verified by dg run --local. playground.arrive
answers "not yet, ask again" against no broker, no URL and no file, so that document can exist
now:
examples/playground/waiting-for-a-batch.yaml.
The routes¶
The instance serves these to itself under /api/v1/playground. They need no credential:
a pipeline step calling its own instance's playground must not need a token, and there is
nothing here to protect -- no database session is opened, no instance data is read, and
nothing is remembered between requests.
They are in the OpenAPI document with everything else, under the playground tag.
One envelope¶
Every route answers the same shape. kind names the answer, request holds what arrived
verbatim, and every other key is what the route decided:
{
"kind": "request",
"request": {
"method": "GET",
"url": "http://127.0.0.1:3333/api/v1/playground/request?station=bergen",
"path": "/api/v1/playground/request",
"args": {"station": "bergen"},
"headers": {"host": "127.0.0.1:3333", "accept": "*/*"},
"body": null,
"body_kind": "none",
"body_bytes": 0,
"body_truncated": false,
"content_type": null
},
"status": 200
}
Reflection is a property of every route, not one route's job. A chosen status comes back
beside the arguments that chose it; a redirect comes back beside the hop count that caused
it. request.args is what was sent, as strings; the keys beside request are what was used,
typed and defaulted.
A body appears exactly once. body_kind says how it was read -- json, form, text
or none -- and content_type says what it arrived under. There is no second copy of a JSON
body under another name. A body larger than 8 KiB comes back as the text of its first 8 KiB, with
body_truncated true and body_bytes still its whole size.
A credential is never reflected. authorization, cookie and proxy-authorization come
back as <redacted>: an origin that reflected a session cookie would hand it to any script
on that origin, which is exactly what httponly exists to prevent.
The routes¶
Every route takes status (the status to answer with, 100 to 599) and delay (how long to
take first, as 250ms or 2s, at most 30 seconds).
A body larger than 1 MiB is refused with a 413, read no further than one byte past that. One that has not arrived whole within 30 seconds is refused with a 408. Every
answer carries X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy, so
a browser that lands on one runs nothing from it.
| Route | Methods | What it does |
|---|---|---|
/playground/request |
GET, POST, PUT, PATCH, DELETE | Reflects, and nothing else. |
/playground/redirect |
GET | Issues a redirect, or a chain of them. |
/playground/unreliable |
GET | Answers a failing status until a given attempt. |
/playground/response-headers |
GET | Sets the query's pairs as response headers. |
/playground/auth |
GET | Requires a credential, and refuses a call without one. |
/playground/stream |
GET | Sends a stream of messages instead of one body. |
/playground/request is the degenerate case: it adds nothing of its own. One path serves
all five methods, so there is no wrong method to answer.
/playground/redirect takes hops (up to 10, default 1), status (301, 302, 303, 307
or 308) and to. Each hop points at the next, and the last points at the destination, which
is /playground/request unless to names another path. to is a path on this instance
and never an absolute URL: an open redirect is a phishing tool wearing the instance's own
domain, and there is nothing to teach that needs one. It is read as a browser reads it, a
backslash as a slash, so /\example.com is refused too.
/playground/unreliable takes attempt (which attempt the caller says this is) and
fail_until, and answers status (default 503) while attempt <= fail_until, then 200. The
window is driven by the attempt the caller names because the playground remembers nothing
between requests, and an answer that depended on what it remembered could not be reproduced.
A document teaching a retry usually wants the node's fail_until instead, which reads the
engine's own attempt number.
/playground/response-headers sets every query argument as a response header. status
and delay are read as knobs and never set as headers. A header that would plant a cookie, ask
for a password, navigate or clear a browser, open this origin to another site, or weaken what a
browser enforces here is refused with a 422: set-cookie, www-authenticate, refresh,
clear-site-data, content-disposition, link, access-control-*, cross-origin-*,
content-security-policy, x-content-type-options, x-frame-options, what a proxy acts on
(x-accel-*, x-sendfile), and the hop-by-hop headers, among others. content-type is one of
application/json, application/x-ndjson, text/csv, text/plain or
text/tab-separated-values, with at most a charset after it, and location is a path on this instance or an absolute URL on
its own origin. Anything else is refused the same way.
/playground/auth accepts the basic pair playground / playground, or the bearer token
playground-token. Both are public constants printed here on purpose: they guard nothing and
unlock nothing but this route's 200. Without one it answers 401 with
WWW-Authenticate: Basic realm="playground".
A body that arrives in pieces¶
/playground/stream is the one route whose answer does not arrive whole, for a consumer
that has to be tested against bytes that turn up over time rather than a body that is already
there. It takes messages (up to 100, default 5), every (the gap before each message, up to
10s, default 1s) and payload (filler of a chosen size on every message, the same filler the
node's payload returns at the same size).
It is NDJSON, application/x-ndjson: one JSON record per line, each carrying a kind,
which is the shape everything this project emits already has. Server-sent events were the other
candidate and were not chosen -- event: would be a second framing vocabulary beside the kind
this project already dispatches on, and the consumers that meet this route are curl | jq -c, a
client library and http.request, none of which speaks EventSource. The instance's own SSE
lives on /runs/{id}/$logs and /runs/{id}/$events, where the consumer is a browser.
The envelope goes out once, on the first line. A stream has one set of headers and many bodies, so the request facts cannot ride on every message the way they ride on every other playground answer. The first line is the envelope, the lines after it carry only what changes, and the last line says the stream ended -- which is how a reader tells an ending from a cut connection.
A stream is bounded whether or not it is read. A call ends 95 seconds after it arrives,
which is the longest delay and span it may ask for and a moment to send what is left, and the
connection is cut without that last line if a client has not read it by then. One process holds 32 streams
at once and answers any more with a 503 (server.playground_streams_full).
$ curl -N 'http://127.0.0.1:3333/api/v1/playground/stream?messages=3&every=100ms'
{"kind":"stream","request":{...},"messages":3,"gap_ms":100,"payload_bytes":null}
{"kind":"stream.message","offset":0,"sent_at":"...","elapsed_ms":101,"payload":null}
{"kind":"stream.message","offset":1,"sent_at":"...","elapsed_ms":202,"payload":null}
{"kind":"stream.message","offset":2,"sent_at":"...","elapsed_ms":303,"payload":null}
{"kind":"stream.closed","messages":3,"duration_ms":303}
The messages carry no generated records, for the reason at the top of this page: a field map is
Faker and the server does not depend on a block package. playground.arrive is where generated
data arrives over time inside a pipeline.
The count and the gap multiply, so each one being inside its own bound is not enough: a stream
that would hold the connection open longer than 60 seconds is refused with a 422. The route
also sets cache-control: no-cache and x-accel-buffering: no, because a stream a proxy holds
until it is whole is not a stream.
Inside a DAG a step reads the whole of it. http.request reads a bounded body and hands it
on, so a step waits for the last line and then gets all of them at once, as text -- NDJSON is
not JSON. The lines are still the value, and transform.jq splits them:
examples/playground/a-stream-to-read.yaml.
Statuses that carry no body¶
A 204, a 304 and the 1xx range cannot carry a body, so a call that asks for one of them gets the status and no reflection. Every other status carries the envelope.
Reaching it from a pipeline¶
A step on the instance's own playground needs the instance's base URL and nothing else:
steps:
call:
block: http.request
config:
url: http://127.0.0.1:3333/api/v1/playground/request
method: GET
query:
station: bergen
A worker on the same host reaches http://127.0.0.1:<port>; a worker in a container reaches
the service name the compose network gives the API. Nothing about the call is special --
there is no token, no connection and no header.
Turning it off¶
DIRIGENT_PLAYGROUND_ENABLED=false, or playground_enabled: false in the configuration
file. Off means the routes are not mounted: every playground path answers 404 and none of
them is in the OpenAPI document. The node is unaffected, because a block is not a route; an
instance that does not want it leaves it out of the documents it applies.
The routes are unauthenticated by design. An instance on a public address that has no use for them should turn them off.