@@ -21,10 +21,13 @@ context and function-type helpers:
2121 sibling jobs cooperatively.
2222- ` Task[T] ` : run one value-returning job and wait for its result once.
2323- ` Pool ` : run accepted jobs with a fixed concurrency limit and bounded backlog.
24- - ` every ` : run a periodic job without overlapping iterations.
24+ - periodic jobs: run a blocking ` every() ` loop or a detached
25+ ` PeriodicHandle ` without overlapping iterations.
2526- ` with_timeout ` / ` with_timeout_context ` : run one job with a bounded deadline.
2627
2728Ticker objects and server-specific helpers are not part of this API.
29+ ` PeriodicHandle ` is only an explicit ` stop() ` / ` wait() ` lifecycle handle for
30+ one detached periodic loop.
2831
2932## Why
3033
@@ -137,9 +140,9 @@ fn worker(mut ctx context.Context) ! {
137140```
138141
139142If a job ignores ` ctx.done() ` , ` Group.wait() ` will still wait for it to return.
140- ` Pool.close() ` and ` every ()` also wait for running non-cooperative jobs to return.
141- ` with_timeout() ` returns when the timeout expires, but the ignored job may keep
142- running until it finishes naturally.
143+ ` Pool.close() ` , ` every() ` , and ` PeriodicHandle.wait ()` also wait for running
144+ non-cooperative jobs to return. ` with_timeout() ` returns when the timeout
145+ expires, but the ignored job may keep running until it finishes naturally.
143146
144147## Group
145148
@@ -175,6 +178,20 @@ Guarantees:
175178keeps the lifecycle simple: create a group, submit jobs, wait once, then discard
176179the group.
177180
181+ Use ` new_group_with_config() ` only when callers need a bounded snapshot of job
182+ errors in addition to the first error returned by ` wait() ` :
183+
184+ ``` v ignore
185+ mut group := async.new_group_with_config(parent, collect_errors: true, max_errors: 8)!
186+ ```
187+
188+ Error collection is opt-in. ` new_group() ` does not collect errors, and
189+ ` errors() ` returns an empty array for that default mode. When collection is
190+ enabled, ` max_errors ` must be positive; collection stores at most ` max_errors `
191+ observed job errors and ` errors() ` returns a copy of that bounded snapshot.
192+ ` wait() ` still returns only the first observed error and never returns an
193+ aggregate. Concurrent error order is not guaranteed.
194+
178195## Task
179196
180197` Task[T] ` represents one concurrent computation that returns either a value or
@@ -252,6 +269,35 @@ Backpressure is explicit. `try_submit()` never waits for backlog space:
252269- if the pool is closed or already waiting, it returns ` async: pool is closed ` ;
253270- if the job function is nil, it returns ` async: job function is nil ` .
254271
272+ Use ` submit_with_context() ` when the caller should wait for backlog space, but
273+ only while a parent context remains active:
274+
275+ ``` v ignore
276+ parent_ctx, cancel := async.with_cancel()
277+ defer {
278+ cancel()
279+ }
280+ pool.submit_with_context(parent_ctx, fn (mut ctx context.Context) ! {
281+ process_message(mut ctx)!
282+ })!
283+ ```
284+
285+ Use ` submit_with_timeout() ` when admission should wait only up to a fixed
286+ duration:
287+
288+ ``` v ignore
289+ pool.submit_with_timeout(250 * time.millisecond, fn (mut ctx context.Context) ! {
290+ process_message(mut ctx)!
291+ })!
292+ ```
293+
294+ The context or timeout bounds admission only. Once the job is accepted, it
295+ receives the pool's context, the same as ` try_submit() ` . If the parent context is
296+ canceled before acceptance, ` submit_with_context() ` returns that parent context
297+ error. If the timeout expires before acceptance, ` submit_with_timeout() ` returns
298+ ` async: timeout ` . If ` wait() ` or ` close() ` starts while a caller is waiting for
299+ admission, the submit call returns ` async: pool is closed ` .
300+
255301Lifecycle:
256302
257303- ` workers ` and ` queue_size ` must be positive and are fixed at creation.
@@ -273,7 +319,7 @@ held.
273319## Periodic Jobs
274320
275321` every() ` runs a job repeatedly until its context is canceled or the job returns
276- an error.
322+ an error. It is blocking and does not start background work:
277323
278324``` v ignore
279325ctx, cancel := async.with_cancel()
@@ -296,10 +342,57 @@ Guarantees:
296342- a job error stops the loop and is returned unchanged.
297343- context cancellation stops the loop and returns the context error.
298344
299- ` every() ` is not a scheduler and does not expose a ticker handle. If a running
300- job ignores cancellation, ` every() ` cannot return until that job returns
345+ Use ` start_every() ` when the periodic loop should run in the background with an
346+ explicit lifecycle handle:
347+
348+ ``` v ignore
349+ ctx, cancel := async.with_cancel()
350+ defer {
351+ cancel()
352+ }
353+
354+ mut handle := async.start_every(ctx, 5 * time.second, fn (mut ctx context.Context) ! {
355+ cleanup_stale_clients(mut ctx)!
356+ })!
357+ defer {
358+ handle.stop()
359+ handle.wait() or {}
360+ }
361+ ```
362+
363+ ` start_every() ` has the same interval, nil-job, first-tick, no-overlap, and job
364+ error rules as ` every() ` , but returns immediately after starting one detached
365+ loop. If the parent context is already canceled, it returns the parent context
366+ error and does not start a worker.
367+
368+ ` PeriodicHandle ` lifecycle:
369+
370+ - ` stop() ` is idempotent and requests cooperative shutdown of the detached loop;
371+ - ` stop() ` is non-blocking and does not kill a running job;
372+ - ` wait() ` blocks until the detached loop exits;
373+ - ` wait() ` is one-shot; a second call returns
374+ ` async: periodic wait was already called ` ;
375+ - a normal ` stop() ` makes ` wait() ` return successfully;
376+ - a job error is returned unchanged by ` wait() ` , even if its message matches
377+ ` context canceled ` ;
378+ - parent cancellation is returned by ` wait() ` as the parent context error.
379+
380+ The detached loop publishes its final result through a bounded channel with
381+ capacity 1, so it can exit even if the owner has not called ` wait() ` yet.
382+
383+ A normal stop is recognized only when the loop itself observes the handle-owned
384+ context cancellation outside a user job error. If parent cancellation is already
385+ observable when ` stop() ` is called, parent cancellation wins and ` wait() ` returns
386+ the parent context error.
387+
388+ If the owner never calls ` stop() ` or the parent context is never canceled, the
389+ detached loop can keep running. If the owner calls ` stop() ` but never calls
390+ ` wait() ` , a running non-cooperative job may still continue until it returns
301391naturally.
302392
393+ Periodic helpers are not schedulers and do not expose ticker objects. They run
394+ one serial loop; a slow iteration delays the next one.
395+
303396## Timeout
304397
305398` with_timeout() ` runs one job with a background context and a timeout:
@@ -329,13 +422,20 @@ async.with_timeout_context(parent, 250 * time.millisecond, fn (mut ctx context.C
329422
330423Error behavior:
331424
332- - if the job returns first, the job error is returned unchanged;
425+ - if the job finishes before the effective timeout deadline, the job error is
426+ returned unchanged;
333427- if the timeout expires first, the public error is ` async: timeout ` ;
334428- if the parent context is canceled first, including when the parent's own
335429 deadline expires first, the parent context error is returned;
336430- a job that observes the local ` x.async ` timeout by returning
337431 ` context deadline exceeded ` is normalized to ` async: timeout ` .
338432
433+ Timeout ownership matters at the deadline boundary. If the job result is received
434+ first but the job finished at or after an ` x.async ` -owned timeout deadline,
435+ ` with_timeout_context() ` still returns ` async: timeout ` . That normalization is
436+ not applied to a shorter parent-owned deadline; parent cancellation keeps the
437+ parent context error path.
438+
339439The result channel used internally is buffered so the spawned job can finish and
340440publish its result even if the caller has already returned on timeout.
341441
@@ -352,8 +452,10 @@ publish its result even if the caller has already returned on timeout.
352452- ` Pool.close() ` drains accepted jobs before returning and reports the first
353453 job error.
354454- ` every() ` is blocking and serial, so periodic iterations cannot overlap.
455+ - ` PeriodicHandle.stop() ` is cooperative, and ` PeriodicHandle.wait() ` is
456+ one-shot so detached periodic lifecycle ownership is explicit.
355457- It uses ` sync.Mutex ` to protect mutable lifecycle/result state in ` Group ` ,
356- ` Task[T] ` , and ` Pool ` .
458+ ` Task[T] ` , ` Pool ` , and ` PeriodicHandle ` .
357459- It keeps ` sync.WaitGroup.add() ` and ` sync.WaitGroup.wait() ` separated by a
358460 lifecycle mutex for ` Group ` and ` Pool ` where accepted work can race with
359461 shutdown.
@@ -365,8 +467,8 @@ validation and resource limits for that domain.
365467
366468This milestone does not include:
367469
368- - blocking pool submission;
369- - ticker objects or detached periodic handles ;
470+ - unbounded blocking pool submission without a context or timeout ;
471+ - ticker objects;
370472- multi-consumer futures or promise chaining;
371473- panic recovery;
372474- scheduler changes;
@@ -379,17 +481,11 @@ replace them with a new runtime.
379481Possible future additions, if accepted by the project maintainers and backed by
380482tests, could still fit the current philosophy:
381483
382- - blocking or timeout-based pool submission, built on existing channels;
383- - detached periodic handles with explicit ` stop() ` / ` wait() ` lifecycle;
384- - helpers that combine ` Group ` , ` Pool ` , ` Task[T] ` , and timeout for server-style
385- code;
386484- more examples and integration tests for other V modules that already use
387485 concurrency;
388486- careful rewrites of existing V modules that need concurrency, if maintainers
389487 decide ` x.async ` makes their lifecycle, cancellation, or backpressure simpler
390488 and safer without breaking compatibility;
391- - optional error collection helpers, as long as first-error behavior stays
392- simple and documented;
393489- more benchmarks and stress tests that remain bounded and reproducible.
394490
395491Other ideas would require a separate design because they move beyond this
@@ -413,6 +509,7 @@ Small runnable examples live in `vlib/x/async/examples/`:
413509- ` basic_group.v ` : first error propagation and cooperative sibling cancellation.
414510- ` basic_task.v ` : run one value-returning task and consume it with ` wait() ` .
415511- ` worker_pool.v ` : fixed concurrency with explicit ` try_submit() ` backpressure.
512+ - ` bounded_pool_submit.v ` : context/timeout-bounded ` Pool ` admission.
416513- ` periodic.v ` : a blocking ` every() ` loop stopped by context cancellation.
417514- ` timeout.v ` : run one cooperative job with a bounded timeout.
418515- ` net_http/ ` : synthetic ` net.http ` request/response work through ` Pool ` .
@@ -446,13 +543,15 @@ V build artefact collisions; it is separate from the runtime guarantees of
446543` x.async ` .
447544
448545The tests cover successful groups, first-error propagation, empty waits,
449- rejected submissions after ` wait() ` , one-shot task waits, task values and
450- errors, pool worker limits, pool queue backpressure, pool close/wait behavior,
451- periodic execution, periodic cancellation, non-overlapping periodic iterations,
546+ rejected submissions after ` wait() ` , optional bounded group error collection,
547+ one-shot task waits, task values and errors, pool worker limits, pool queue
548+ backpressure, pool close/wait behavior, bounded pool submission with context
549+ cancellation and timeout, periodic execution, detached periodic handle stop/wait
550+ lifecycle, periodic cancellation, non-overlapping periodic iterations,
452551cooperative cancellation, timeout errors, parent cancellation, nil job rejection,
453552parent deadline preservation, invalid intervals, zero/already-expired timeouts,
454- stress cases, and jobs that ignore cancellation. Internal tests also verify
455- that the derived ` AsyncContext ` closes ` done() ` and propagates parent
553+ stress cases, and jobs that ignore cancellation. Internal tests also verify that
554+ the derived ` AsyncContext ` closes ` done() ` and propagates parent
456555cancellation.
457556
458557Module-oriented integration tests live in ` vlib/x/async/tests/ ` . They are
@@ -470,11 +569,13 @@ sh vlib/x/async/benchmarks/run_async_benchmark.sh
470569
471570The script uses the local ` ./v ` , runs serially, and isolates ` VTMP ` , ` VCACHE ` ,
472571and the benchmark executable output. It measures short default runs for
473- ` Group ` , ` Task[T] ` , ` Pool ` , ` with_timeout() ` , and ` every() ` .
572+ ` Group ` , ` Task[T] ` , ` Pool ` , bounded pool admission, ` with_timeout() ` , and
573+ ` every() ` .
474574
475575The default sizes are intentionally modest. They can be changed with
476576` XASYNC_BENCH_GROUP_ROUNDS ` , ` XASYNC_BENCH_GROUP_JOBS ` ,
477577` XASYNC_BENCH_TASK_ROUNDS ` , ` XASYNC_BENCH_POOL_JOBS ` ,
478- ` XASYNC_BENCH_POOL_WORKERS ` , ` XASYNC_BENCH_TIMEOUT_ROUNDS ` ,
578+ ` XASYNC_BENCH_POOL_WORKERS ` , ` XASYNC_BENCH_POOL_BOUNDED_ROUNDS ` ,
579+ ` XASYNC_BENCH_POOL_BOUNDED_TIMEOUT_MS ` , ` XASYNC_BENCH_TIMEOUT_ROUNDS ` ,
479580` XASYNC_BENCH_EVERY_ITERATIONS ` , and ` XASYNC_BENCH_EVERY_INTERVAL_MS ` .
480581Benchmark output is local diagnostic data, not a portable performance claim.
0 commit comments