Skip to content

feat(js): support async callbacks in group() - #6341

Merged
mstoykov merged 1 commit into
masterfrom
async-group-base
Sep 15, 2026
Merged

mstoykov merged 1 commit into
masterfrom
async-group-base

Conversation

@mstoykov

@mstoykov mstoykov commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

What?

Stack 2 of 6

Base: feature/async-context (stack PR 1, #6340)

Head: async-group-base

The next PR will target this branch and add timer callback propagation.

When async-metric-context is enabled, this PR allows group() callbacks to return Promises or
Promise-like thenables. The group's complete metric context follows Promise reactions, and
group_duration is emitted once when the returned asynchronous result settles.

group() still invokes its callback immediately. A non-thenable result follows the synchronous path
and is returned unchanged. For an asynchronous result, the implementation:

  • snapshots the parent context and the group's entry context;
  • restores the parent as soon as the callback returns or throws;
  • attaches per-invocation fulfillment and rejection handlers;
  • preserves the fulfillment value or rejection reason;
  • uses a native-Promise fast path and assimilates generic thenables into a real Sobek Promise; and
  • emits one duration sample on fulfillment or rejection, including for synchronously settling or
    throwing custom thenables.

The disabled path retains the existing rejection of native async callbacks and the existing mutable
tag behavior for synchronous callbacks.

This deliberately makes synchronous and asynchronous groups use the same duration context when the
feature is enabled. group_duration uses the tags and metadata captured at group entry. Mutations
made inside the callback affect metrics emitted there, but they do not retag group_duration and the
complete parent context is restored when group() returns. Without the feature, the legacy
synchronous path records group_duration with the context present when the callback finishes and
leaves non-group tag and metadata mutations live.

Five one-second benchmark runs with GOMAXPROCS=1 measured an awaited resolved Promise at
4.934 us/op, 5,376 B/op, and 89 allocations with the feature enabled, versus 4.828 us/op,
5,328 B/op, and 87 allocations with it disabled. Returning a group around a resolved Promise
measured 8.796 us/op, 8,424 B/op, and 135 allocations; awaiting it measured 11.711 us/op,
10,584 B/op, and 173 allocations.

Why?

Today, group() restores its tag as soon as the callback returns. Work after an await or inside a
Promise handler therefore loses the group, and group_duration measures only the synchronous prefix.
That makes groups unreliable for k6's increasingly asynchronous APIs, as reported in #2728 and
summarized in #5435 and #5673.

Duration ownership is kept separate from the general context tracker. The tracker answers which
metric context a Promise reaction should run under; each group() invocation owns its own start time,
entry snapshot, and settlement state. This separation is what lets several groups overlap without one
group completing or retagging another.

Using the entry snapshot for group_duration also keeps its attribution independent of when a
callback mutates tags or metadata. Applying completion-time context only to synchronous callbacks
would preserve the old behavior under the feature flag, but would make otherwise equivalent sync and
async groups report duration with different contexts.

The callback is intentionally called before checking its return value. This preserves the existing
synchronous group() behavior and supports ordinary, bound, proxied, or transpiled functions that
happen to return a Promise. It also preserves the execution order A, C, B for a callback that does
work A, awaits, then does work B, followed by synchronous caller work C.

Alternatives considered:

  • k6: group special async function treatment #2863 detected the callback's AsyncFunction class and deferred the whole callback through a
    Promise job. That changes ordering to C, A, B, misses ordinary functions returning Promises, and
    couples group lifetime to async-function tracking rather than the returned value.
  • Change how group() calls async functions #2848 proposes deferring async callback invocation. This PR deliberately does not adopt that
    scheduling change; its regression test locks in immediate invocation. The issue is related but is
    not closed by this PR.
  • Looking only for native Promises would be simpler, but valid Promise-like return values would either
    finish too early or make await group(...) resume with the return value of then(). Generic
    thenables are therefore assimilated into a real Promise.
  • Emitting duration when the callback returns would preserve legacy timing but would not measure the
    asynchronous operation. A never-settling result consequently has no completed duration, matching
    the fact that the group itself never completes.

Checklist

  • I have performed a self-review of my code.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests for my changes.
  • I have run linter and tests locally (make check) and all pass.

Checklist: Documentation (only for k6 maintainers and if relevant)

Please do not merge this PR until the following items are filled out.

  • I have added the correct milestone and labels to the PR.
  • I have updated the release notes: TBD for the complete experimental stack
  • I have updated or added an issue to the
    k6-documentation: TBD; the group() documentation must
    describe the feature flag, asynchronous lifetime, and entry-context semantics of group_duration
  • I have updated or added an issue to the
    TypeScript definitions:
    not applicable; the existing generic return type already preserves a Promise returned by the
    callback

Related PR(s)/Issue(s)

@mstoykov mstoykov added this to the v2.3.0 milestone Aug 26, 2026
@mstoykov
mstoykov requested a review from a team as a code owner August 26, 2026 12:59
@mstoykov
mstoykov requested review from inancgumus and szkiba and removed request for a team August 26, 2026 12:59
@mstoykov
mstoykov temporarily deployed to azure-trusted-signing August 26, 2026 13:05 — with GitHub Actions Inactive
@mstoykov
mstoykov temporarily deployed to azure-trusted-signing August 26, 2026 13:06 — with GitHub Actions Inactive
szkiba
szkiba previously approved these changes Sep 11, 2026

@szkiba szkiba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Good work.

inancgumus
inancgumus previously approved these changes Sep 11, 2026
Base automatically changed from feature/async-context to master September 15, 2026 07:53
When async-metric-context is enabled, allow group() callbacks to return
Promise-like values. Keep the group's complete metric context across
await continuations and promise reactions, and emit group_duration once
when the result settles.

Restore the parent context after the callback returns or throws. Handle
throwing then accessors and custom thenables that settle synchronously,
preserve rejection identity, and prevent duplicate duration emission.

Disabled runs retain the previous async-function error and synchronous
tag behavior. Synchronous callbacks continue to return their original
values without entering the asynchronous settlement path.

Five one-second runs with GOMAXPROCS=1 measured an awaited resolved
promise at 4.934 us/op, 5,376 B/op, and 89 allocations with the feature
enabled. The disabled path measured 4.828 us/op, 5,328 B/op, and 87
allocations. Timing ranges overlapped, while the allocation difference
was stable.

Returning a group around a resolved promise measured 8.796 us/op,
8,424 B/op, and 135 allocations. Awaiting the group measured
11.711 us/op, 10,584 B/op, and 173 allocations.
@mstoykov
mstoykov dismissed stale reviews from inancgumus and szkiba via 9eb1c67 September 15, 2026 07:53
@mstoykov
mstoykov temporarily deployed to azure-trusted-signing September 15, 2026 07:59 — with GitHub Actions Inactive
@mstoykov
mstoykov temporarily deployed to azure-trusted-signing September 15, 2026 08:01 — with GitHub Actions Inactive
}

func asyncGroupsEnabled(vu modules.VU) bool {
if state := vu.State(); state != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: nil check is obsolete :)

if common.IsAsyncFunction(mi.vu.Runtime(), val) {
if !mi.asyncGroups && common.IsAsyncFunction(mi.vu.Runtime(), val) {
return sobek.Undefined(), errors.New("group() does not support async functions as arguments, " +
"please see https://grafana.com/docs/k6/latest/javascript-api/k6/group/ for more info")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could update this error message to explain that there is a feature flag now

@mstoykov
mstoykov merged commit ed3ead1 into master Sep 15, 2026
65 of 66 checks passed
@mstoykov
mstoykov deleted the async-group-base branch September 15, 2026 09:12
inancgumus added a commit to grafana/k6-docs that referenced this pull request Sep 15, 2026
Document how to enable asynchronous groups and keep metrics tied to
their operation across promises, timers, and protocol callbacks.

Related: grafana/k6#6340
Related: grafana/k6#6341
Related: grafana/k6#6342
Related: grafana/k6#6343
Related: grafana/k6#6344
mstoykov added a commit that referenced this pull request Sep 17, 2026
…rror (#6408)

When group() is called with an async callback and the async-metric-context
experimental feature is not enabled, the returned error now tells the user
how to enable it (--features async-metric-context / K6_FEATURES) instead of
implying async callbacks are unsupported outright.

Addresses review feedback on #6341.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
azure-trusted-signing — 9eb1c67a Deployed Sep 15, 2026 by mstoykov via sign-packages #10669
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants