Seq and Seqq
Why sequences
Sometimes you just want to run some steps one after another. Graphix
could always express that, but only verbosely: a state variable, a
select over it, ~ guards on every input so a step waits for the one
before it, and a <- to advance the state. The dataflow was correct
but difficult to read.
Within a seq you write statements the way you would in an ordinary
language, and the compiler transforms them into that state machine for
you.
seq trigger {
do_step_1(x);
do_step_2(y);
do_step_3(z)
}
In ordinary Graphix do_step_1, do_step_2 and do_step_3 would all run
concurrently, each firing whenever its own inputs fire. Inside a seq only
one statement is running at a time. Each statement runs until it produces a
value; then it stops and the next one starts. The run is started by the trigger;
without one, the block runs once at initialization (or once per outer run for a
nested block).
A seq is still a Graphix expression. Its value is the value of its last
step, produced once per completed run, so you can bind it, connect it
to a variable, or feed it to another expression.
seq let name = expr { ... } names the trigger’s value for the body,
and nothing outside the block sees the name. Like seq name { ... }
over a variable, the name is the value the run started with, and the
pattern may destructure (seq let {x, y} = point { ... }):
seq let session = (*connected)$ {
known <- upsert(known, session.domain, session.server, session.fingerprint);
save <- session
}
A seq can also be a step of another seq. Written without a trigger
it runs every time its statement is reached, and the enclosing step
waits for that run to complete:
seq request {
let cfg = seq { let raw = read_config(request); parse(raw) };
apply(cfg)
}
An error inside the inner seq aborts the inner run and, unless a
try in the outer body takes it, the outer run as well. A nested
seq costs two cycles more than the same statements written inline.
seq and seqq
The two forms differ in what happens when the trigger fires while a run is in progress:
seq trigger { ... }drops the trigger. Use it for things that should not pile up, such as a refresh button.seqq trigger { ... }queues the trigger, with one run active at a time. Use it when every request must be served, such as a stream of jobs. The queue is unbounded; see Captured inputs for what a queued request carries.
The trigger is any expression. A burst of triggers that should cost one
run, such as a key held down, is debounced at the trigger:
seqq sys::time::after_idle(duration:250.ms, key) { ... } runs once,
250ms after the last press.
let request = count(sys::time::timer(duration:20.ms, 3)?);
let label = "job";
seqq request {
let reply = sys::time::after_idle(duration:60.ms, "[label] [request]");
println(reply);
reply
}
This processes all three requests in order. The block’s last step supplies
its output; each new output releases the next queued request, even when the
output equals the previous one. Effectful calls must produce a completion
value: println, for example, returns null after printing. never()
stalls the current run and therefore stops the queue from advancing.
Statements
A seq body is straight-line: a list of statements, each of which is one
step. There is no if or looping inside a seq; branch with select
inside a step, or call a function. The statement kinds are:
An expression. f(x); is a step that completes when the expression
produces a value. The last expression of the block is its output.
let x = e; is a step like any other, and additionally carries the
value it produced into every later step. Once its step has passed, x
keeps that value: a later change of e’s inputs while a later step
waits is not seen. A later let x = ... creates a
new binding. Its initializer sees the preceding x;
earlier references and closures continue to refer to that preceding
binding. The new binding can have a different type.
x <- e; connects, as it does anywhere. The destination is the
original variable outside the block, and the step completes when e
produces. A later statement that reads x sees the write.
until cond; waits until the boolean cond is true, reading it live
rather than as a queued copy. It has no value, so it cannot be the last
statement of a body whose value is used.
{ s1; s2; ... } runs several statements together as one step.
Every statement in the block is issued when the step starts, so two
asynchronous calls in one block are in flight at once, where at the seq
level the second would wait for the first (see below). A let inside a
block is local to it; a later statement in the block that uses it waits
for it, as ordinary dataflow does. Connects and calls in a block are
still clocked to the step: they issue once, when the block is entered,
not again when their inputs move. The block completes when every
statement in it has produced, with the value of its last statement, so
a block can end a seq or initialize a let. Inside a block you are
back in ordinary Graphix: a statement that reads a variable a sibling
wrote sees the old value, and an error in one statement does not
retract a write a sibling already issued. until and try are refused
inside a block.
A declaration (use, type, trait, impl, mod) is not a step: write
it in a block with the code that uses it, or outside the seq. An
attribute on a statement annotates the statement’s computation, as it
would outside a seq (#[native] let y = x * 2;); a try statement takes
none.
try { steps } with(e) { steps } is the sequence’s error handling.
An error raised in the try body transfers control to the with body; see
Errors.
When the next statement starts
A statement starts in the first cycle where the effect of the one
before it can be seen. For a let or a call the effect is its value,
and dataflow carries it at once, so let a = f(); let b = g(a) issues
g in the cycle f produced. For a connect the effect is its write,
which lands the next cycle, so a statement that reads a variable an
earlier statement wrote starts the next cycle and reads the new value:
n <- n + 1; publish(n) publishes the new n. Connects that nothing
in between reads land together: a <- x; b <- y; let s = a + b writes
a and b in one cycle and binds s the next.
The compiler decides this after it has resolved every call, following
each call into the function it reaches. A write a function makes counts
like one the statement makes itself: put(5); let s = b, where let put = |v| { b <- v; v } writes the b it captured, reads the new b. (A
step must produce a value to complete: written |v| b <- v, put’s
value would be a connect’s, which never produces, and the run would
stall.) A call to a function
that does not read a does not wait for a <- .. to land. Two things
count as touching every variable, because the compiler cannot name what
they touch: a read or write through a reference (*r), and a call
whose function is only known at run time (one stored in a struct or an
array). A statement after one of those that reads anything outside the
seq starts a cycle later. until and try follow the same rule. When
you know two calls are independent, put them in a block: { a <- f(x); b <- g(y) } issues both at once.
graphix --expand file.gx checks the file and prints each sequence’s
lowered program: the step variable and one step per statement, then,
for each instance, which steps start in the cycle the one before them
completes (S0 -> S1 same) and which wait a cycle (S1 -> S2 next).
It is the tool for seeing exactly which event a step is waiting on and
which statements share a cycle.
Call inputs
In both forms, each call waits until all its explicit arguments are
present, then samples them together with ~! and issues once. This gives
standing values a fresh call event, including values carried from an
earlier step:
seq request {
let x = request;
let f = |v| v ~ x;
f(request)
}
If an argument is bottom at entry, the call waits; it cannot reuse a previous run’s snapshot. After issuance, later argument changes or bottom do not change the snapshot or interrupt the pending result. Nested calls get their own snapshots. A call without arguments is read at entry like a level.
A step completes when it produces a new value after its entry, never on a
value left standing from an earlier run. A call’s answer is what the call
produces after it is issued; a step that reads a level (let y = x,
until flag) takes the level as it stands at entry, and waits for it if
it is absent. One consequence: a function that returns a level it does not
derive from its argument (|v| k) produces nothing new when called
again, so a step calling it never completes after the first run. Sample
the level on the argument instead (|v| v ~ k).
This clocks the call site, not the function body: callbacks and captured
state inside a function remain reactive. References are sampled as
handles, not copies of their contents. Calls inside until conditions and
a with body’s steps are steps like any other.
Captured inputs
seqq captures the external values read by its body. At each trigger it
samples them together into one tuple and queues that tuple. Later changes
to those inputs do not change a queued request or reissue a waiting step.
Bindings declared inside the block retain their ordinary scope.
A capture uses its last produced value; it need not produce on the same cycle as the request. If its source becomes bottom, its previous value is still usable. These snapshots can therefore contain stale data.
Until every capture has produced at least once, there is no complete tuple to sample. Requests wait for that first tuple, and those early requests can share the first available snapshot, including its latest trigger value. Initialize required inputs before issuing requests if each request must carry its exact arrival-time values.
Capture is shallow: references retain their identity and remain live when dereferenced. Capturing a function does not snapshot the external state read inside that function.
A write through a captured reference uses that queued handle, even if the source reference variable subsequently points somewhere else.
Live state and waits
until ready observes the live condition, rather than a queued copy of it.
The one name it does not read live is the trigger’s: seqq request { until served == request; .. } waits on the request this run is serving, as the
same block under seq does.
Connect destinations remain the original variables. An external variable
the block writes, directly or through a function it calls, also remains
live when read, so queued count <- count + 1 operations can accumulate
rather than overwrite one another with an old count. Taking &state
refers to the original state. A write through a reference does not make
anything live: the reference’s target stays what it was captured as. So a
body that writes through a reference and reads the target by name,
*r <- a + 1 with r = &mut a, reads a from each request’s snapshot,
and queued requests overwrite one another. Read through the reference
too: *r <- *r + 1 reads the target live and accumulates.
Keep persistent effects such as subscriptions and tui::suspend outside
the block; steps can update their inputs and wait for their responses.
Errors
A thrown error aborts the current request, resets the sequence, and
rethrows to the enclosing handler. It is not a successful block output.
If a run raises several errors, each is rethrown. The sequence resets
and releases the queue only once, after those deliveries, keeping the
failed request’s captured inputs in place. This does not wait for
asynchronous work started by an enclosing handler. Sample that
handler’s side-effect arguments on the error, for example
println(e ~ "failed [request]"), so later captured-value changes do
not reissue the effect.
catch is not allowed anywhere inside a seq body. A catch is an
install: it can observe an error but it cannot produce the value the
next step is waiting for, so inside a sequence it could only rethrow
or stall the run. (A lambda literal is its own scope and may contain
one: inside a function you are back in ordinary Graphix.) Error
handling inside a sequence is control flow:
seq req {
let code = try {
let child = sys::process::spawn(options(req))?;
let status = sys::process::wait(child.proc)?;
status.code
} with(e) {
toast <- failed(e);
-1
};
report(code)
}
try { steps } with(e) { steps } is a seq statement. An error raised
anywhere in the try body, by a ?, by a function the body calls, or
inside a fused region, transfers control to the with body’s first step
with e bound to the first error of the failure (a step that raises
several errors in one cycle delivers only the first; the rest are
consumed). The with body’s last step continues to the statement after
the try. The statement’s value is whichever body ran, so
let x = try { .. } with(e) { .. }, x <- try { .. } with(_) { .. }
and a bare try as a statement or as the block’s last expression all
work. The with body’s value must fit the try body’s type; annotate the
let to recover into a wider one, let x: [i64, null] = try { f()? } with(_) { null }.
An error raised in the with body goes to the enclosing try, else to
the sequence, which aborts as usual. Cleanup that still fails the run
is therefore with(e) { cleanup; e? }, and the error reaches the
handler around the seq exactly once. A let bound in the try body is not
visible after the try; e is visible only in the with body;
with(e: T) ascribes T to e and requires it to cover everything the
body can throw. try is a seq statement: it is refused inside a block
and outside a seq.
An error reaching the sequence’s handler takes precedence over a value produced by the same step. The failing step cannot publish a result or start the statements after it. This is not rollback: effects already performed inside an ordinary expression remain performed, and the statements of a block are issued together, so a sibling’s write is not retracted.
Aborting a run
abort(event) in the head, separated from the trigger by ;, ends the
run in progress when event fires:
seq go; abort(cancel) {
let child = sys::process::spawn(options(cmd))?;
let status = sys::process::wait(child.proc)?;
report(status.code)
}
The run stops where it is. The block produces no value, no error is
raised, and a try around the current step is not taken: an abort is
not a failure to recover from. The sequence is idle and the next
trigger starts a fresh run. The step that was waiting drops its work as
any sleeping expression does (a pending timer is cancelled, a request
in flight is forgotten), so nothing from the aborted run arrives in the
next one.
The event is evaluated as the run’s first step. It is asleep between runs and wakes when a run starts, so a timer in it times the run, and an event that fired while nothing was running aborts nothing:
// a ten second budget for each run
seq go; abort(sys::time::timer(duration:10.s, false)) { ... }
// the trigger's name is this run's trigger
seqq let job = jobs; abort(sys::time::after_idle(job.budget, job)) { ... }
Only fires after the run has started count; a value the event already holds when the run starts is not a fire.
Nothing is undone. Writes the run already made stay made, and a value
bound by a let stays alive until the next run replaces it, a spawned
process included. Undo what needs undoing outside the block, on the
same event: busy <- cancel ~ false. A child that must die with the
run is written to a variable outside the block rather than bound with
let.
Under seqq, abort ends the current run and the next queued request
starts. flush(event) also empties the queue:
seqq request; abort(skip); flush(cancel_all) { ... }
flush follows abort when both are present, and only seqq accepts
it. A request that arrives with the flush or after it is kept.
Sleeping
A sequence inside a select arm, or inside a function called from one,
sleeps with the arm. A run in progress does not survive that: the
sequence is idle when the arm wakes, since a wake resumes the arm and
starts nothing. A seq without a trigger in an arm runs only at the
arm’s first entry, and the check warns of it: to run at every entry,
trigger it on the event that selects the arm. A seq nested in a step
of an outer run that was aborted is idle too; a triggerless one starts
again when a later run enters its step.
The queue is unbounded. A producer faster than the block can consume will
grow it; a permanently stalled run can retain all subsequent requests.
Sleeping the enclosing expression keeps the queued requests, as queue
keeps its contents: they are served after the wake, and the credit of
the run the sleep aborted is returned then.