Paul King created GROOVY-12343:
----------------------------------

             Summary: Provide AsyncChannel.after
                 Key: GROOVY-12343
                 URL: https://issues.apache.org/jira/browse/GROOVY-12343
             Project: Groovy
          Issue Type: Improvement
            Reporter: Paul King
            Assignee: Paul King


h2. Summary

A {{ChannelSelect}} cannot have a timeout branch. Every comparable system has 
one — it is how "wait for
work, but not forever" is written — and Groovy is currently the outlier.

There is a workaround, and it is worth being precise about how far it goes, 
because it goes further than it
first appears.

h2. What a user writes today

{{Awaitable.orTimeout}} wraps the whole select:

{code:groovy}
try {
    def r = await alt.select().orTimeoutMillis(100)
    ...
} catch (TimeoutException e) {
    ... // the timeout branch
}
{code}

This is *safe*. {{AsyncSupport.completeOnTimeout}}/{{orTimeout}} cancel the 
source when the deadline fires,
and {{Winner.cancel}} is {{claim.tryCommitCancel() && super.cancel(…)}}, so the 
cancellation wins the claim
and no offer commits. GROOVY-12320's protocol already makes the outer timeout 
correct — nothing is consumed
and lost. That is worth saying plainly, since it is the first thing a reviewer 
will worry about.

What it does not give:

* *Exceptions for expected control flow.* A timeout that is part of the design 
is signalled by
  {{TimeoutException}} rather than by a branch.
* *No fallback form.* {{completeOnTimeout}} needs a fallback value, and 
{{ChannelSelect.Result}}'s
  constructor is package-private — a caller cannot construct one. So only the 
throwing form is usable here.
* *One deadline for the whole select.* Different deadlines on different 
branches need nested selects or
  extra tasks.
* *No guarded timeout.* The deadline sits outside the select, so {{Offer.when 
\{ … \}}} (GROOVY-12326)
  cannot reach it. "Time out only while the buffer is empty" — never 
mid-transaction — is not expressible.
* *No composition with the choice policy.* The timeout is not a branch, so it 
is outside {{fair()}} /
  {{random()}} rotation and outside the {{select(boolean...)}} mask.

h2. What it enables

Ordinary, and common:

* wait for work, and on silence emit a heartbeat, retry, log, or shut down;
* serve requests while a periodic tick flushes a batch, emits metrics, or polls 
for shutdown.

Both are achievable today with try/catch. The two that are genuinely awkward 
are per-branch deadlines and
the guarded timeout above.

h2. Prior art — everyone has this

|| System || How the timeout appears || Timer is… ||
| Go | {{case <-time.After(d):}} | a channel |
| Rust (crossbeam-channel) | {{recv(after(timeout)) -> _ =>}} | a channel 
({{after(Duration) -> Receiver<Instant>}}) |
| Clojure core.async | {{(alts! \[ch (timeout 1000)\])}} | a channel |
| Kotlin coroutines | {{select \{ onTimeout(ms) \{ … \} \}}} | a select clause |
| Erlang | {{receive … after Timeout ->}} | language syntax |
| Ada | {{select … or delay 5.0;}} | language syntax |
| JCSP | {{CSTimer}} in the {{Guard\[\]}} | a guard kind |
| *Groovy 6 today* | *— (outer wrapper only)* | *—* |

Kotlin is the closest precedent for Groovy's situation: it has BOTH the outer 
wrapper
({{withTimeout}}/{{withTimeoutOrNull}}) and the branch ({{onTimeout}}). Having 
the wrapper is not treated as
making the branch redundant.

Note what Go, Rust and Clojure share: *the timer is a channel*, not a special 
branch kind. That shapes the
proposal below.

h2. Option A (preferred) — a timer CHANNEL

{code:java}
public static AsyncChannel<java.time.Instant> after(long millis)
public static AsyncChannel<java.time.Instant> after(java.time.Duration duration)
{code}

A capacity-1 channel that delivers one element — the instant it fired — after 
the given delay.

{code:groovy}
def alt = ChannelSelect.from(work, AsyncChannel.after(100))
def r = await alt.select()
if (r.index == 1) { ... timed out ... }
{code}

*Why this one.* It needs *no change to {{ChannelSelect}} at all*, and it 
composes with everything already
there for free: {{from(…)}} and {{offers(receive(…))}}, {{Offer.when \{ … \}}} 
for the guarded timeout,
{{fair()}} / {{random()}} rotation, the {{select(boolean...)}} mask, and 
{{Result.getChannel()}} (which
identifies the timer like any other branch, rather than having to return null). 
It is also useful *outside*
a select — {{await AsyncChannel.after(50).first()}} as a delay, or as one arm 
of {{Awaitable.any}} — where a
select-only offer would not reach. And it is the design three of the four 
comparable ecosystems chose.

*Implementation.* A channel whose element is produced by one scheduled task, on 
the same scheduler
{{AsyncSupport}} already uses for {{orTimeout}}. The single new concern is 
*cancellation*: when a select
withdraws its losing branches the timer's {{ScheduledFuture}} should be 
cancelled too, or an abandoned
select leaves a task pending until it fires. That is one thing to get right, 
against five for Option B.

h2. Option B — a timer OFFER

{code:java}
public static ChannelSelect.Offer after(long millis)
{code}

Symmetric with the existing {{send(…)}} / {{receive(…)}} offers, and closest to 
JCSP's {{CSTimer}} guard.

The cost is spread wider. {{Offer}} currently always has a channel, so it gains 
a nullable one plus a delay
(or a subtype); {{Result.getChannel()}} must return null for a timer win, and 
say so; the registration loop
in {{select(int\[\])}} gains a third path beside {{sendIfUnclaimed}} / 
{{receiveIfUnclaimed}} — though that
path is largely written already, since a timer can compete for the claim 
exactly as a non-claimable foreign
channel does, via {{winner.claim.tryCommit(future)}}. Withdrawal must still 
cancel the scheduled task.

Option B is defensible if the symmetry with {{send}}/{{receive}} matters more 
than the reuse; it is listed
so the choice is explicit rather than implied.

h2. Open questions

* *When does the clock start?* At creation, as in Go and crossbeam, is the 
obvious answer — and it means a
  fresh timer per loop iteration, so a long-running loop allocates one 
scheduled task per round. Go carries
  the same caveat for {{time.After}} in a loop and answers it with a resettable 
{{Timer}}. Worth deciding
  whether Groovy wants a resettable form now or later.
* *Periodic ticks.* Go has {{time.Tick}} and crossbeam {{tick(d)}} — a channel 
that fires repeatedly. That
  is the natural companion for "every 100ms, flush", and is probably a separate 
issue rather than part of
  this one.
* *Element type.* {{Instant}} matches crossbeam and is more useful than 
{{null}} or a token; Go sends the
  time too.

h2. Provenance

Found while porting Jon Kerridge's _Using Concurrency and Parallelism 
Effectively_ onto
{{groovy.concurrent}} with a static verifier — the same exercise behind 
GROOVY-12320, GROOVY-12323,
GROOVY-12324 and GROOVY-12326. Three of the book's chapters put a timer in an 
{{ALT}} (c05's scaling device,
c14's hand-eye test, c17's sampling {{Sniffer}}), and none of them ports: the 
timeout is part of the choice
in each, not a wrapper around it. With input, output and boolean guards now all 
present, the timer is the
one member of JCSP's guard family Groovy's select still lacks.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to