Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to use imported async functions in test script? #779

Closed
kkeranen opened this issue Sep 25, 2018 · 12 comments
Closed

How to use imported async functions in test script? #779

kkeranen opened this issue Sep 25, 2018 · 12 comments
Milestone

Comments

@kkeranen
Copy link

It seem that async code is not supported in k6, so how imported async methods should be used? I demonstrate the problem by two examples.

For context initialization I would simply like to code something like:

async () => {
  await login();
}

The reason being that login procedure is quite complex and for that we have some utility functions that are asynchronous. I could re-write the login functions in synchronous way, but that would be mostly implementing some thing twice because testing framework (k6) doesn't support it. So it doesn't sound such a good idea.

Also, in the actual test code (default function) I would like to call our microservice via HTTP API wrapper, not HTTP directly, such as await serviceClient.createTree(/*some parameters*/);. That would be more realistic, a bit closer to end-to-end testing. Again, duplicating the code and making it synchronous because k6 doesn't support async code seems a bad idea. Are there any better workarounds?

@micsjo
Copy link
Contributor

micsjo commented Sep 25, 2018

On the second topic, API wrappers.

If you have them properly specified you can use tools such as Swagger Codegen to generate a javascript client wrapper for it. If that's how you actually want to build your tests it works fine. The you will have to adjust the implementations to use the k6 request APIs rather than plain js but if it's something that happens a lot you might find it worth while to implement in Codegen.

@na-- na-- added the js-compat label Sep 26, 2018
@na--
Copy link
Member

na-- commented Sep 26, 2018

k6 doesn't currently support promises and async/await calls, or even simpler things like setTimeout(), mostly due to the lack of a global event loop in each VU. That's something we're planning to eventually fix, both because it would improve code reuse and general JS support, but mostly so that we can implement different async APIs that would allow the use of a broader array of JS libraries in k6 tests.

As briefly described here, the current synchronous k6 execution model mostly works because k6 runs multiple JS goja runtimes in parallel. The current implementation is simpler and it's sufficient for most load testing purposes, but it locks us out of the broader JS ecosystem of tools and libraries, and makes code reuse somewhat difficult as you've found out. As mentioned here, we have a localized mini-event-loop in the case of websocket connections, but it's very bounded and much more manageable than a global one.

When we add an event loop, we'd probably be able to somewhat reuse the simple event loop in the goja_nodejs project, though we have to add some k6-specific restrictions as well. Preventing foot-guns and unexpected behavior with the combination of VU async code via an event loop and the current looping VU behavior (or even worse - with the planned arrival-rate based execution) would be a challenge - we'd most likely have to restrict events so that they don't cross over between different VU iterations.

No idea what the best solution for that would be, probably some configurable timeout that would specify how long k6 would wait for events to finish after the end of an iteration before it clears any stragglers and starts the next iteration. Not sure if that would be enough or if other restrictions would be necessary as well. We'd likely open a separate issue in the future to discuss the actual implementation details and different design trade-offs of an event loop, but any comments on that are welcome here as well.

Connected/duplicate issue: #706

@InsOpDe
Copy link

InsOpDe commented Aug 14, 2020

I struggled with this as well and found a workaround - we need webpack to polyfill Promises
First we have to solve this problem #706
we can do this by adding "@babel/plugin-transform-runtime" to .babelrc plugins. (and install it via yarn add -D @babel/plugin-transform-runtime.)

Now we only need to polyfill setTimeout. I did it like so:

global.setTimeout = function(cb, ms = 0) {
	sleep(ms/1000)
	if(typeof cb === "function") {
		cb()
	}
};

note that I did not implement clearTimeout nor does my polyfill have a return value. It does work anyway.
With these set up I can run code like such:

const timeout = async (ms: number): Promise<void> => {
	return new Promise((resolve, reject) => {
		setTimeout(resolve, ms)
	})
}
export default async (): Promise<void> => {
	const now = Date.now()
	await timeout(1000)
	console.log(Date.now() - now) // returns 1000
}

Note that If I would now use promises like so:

export default async (): Promise<void> => {
	const now = Date.now()
	timeout(1000).then(res => console.log("foo"))
	console.log(Date.now() - now)
}

Would return:

"foo"
1000

in that order. Since everything is synchronous now.

This should work for most cases but we still cant use fs or XMLHttpRequest (in case you wanted to use axios or nodes http module)

@mstoykov
Copy link
Contributor

For the record in v0.31.0 we dropped some of the babel plugins and core-js polyfills (Promise) that were helping the above workaround.

In our opinion, while this workaround "worked" it is much better for most users to get better error messages for what k6 doesn't support.

Also whoever is using the workaround, already needs a specific setup so requiring to polyfill Promise and possibly some more babel plugins shouldn't be

@mstoykov
Copy link
Contributor

mstoykov commented Nov 9, 2021

Using #2228 (which is a WIP and will likely change) and https://github.com/grafana/k6-template-es6 you can now transpile

import "https://raw.githubusercontent.com/facebook/regenerator/main/packages/runtime/runtime.js"

const timeout = async (ms)=> {
        return new Promise((resolve, reject) => {
                setTimeout(resolve, ms)
        })
}
export default async () => {
        console.log("start")
        const now = Date.now()
        console.log("before wait")
        await timeout(1000)
        console.log(Date.now() - now) // returns 1000
        console.log("end")
}

and you will get

INFO[0000] start                                         source=console
INFO[0000] before wait                                   source=console
INFO[0001] 1001                                          source=console
INFO[0001] end                                           source=console

🎉
This is now possible as Promise is now part of goja(to be released with v0.35.0, soon) and #2228 implements setTimeout (to an extend).

This still though uses babel to transpile the async/await and as can been seen by the resulting code

You can see that the code is ... not great. Basically, any async function will get to be a switch wrapped in endless loop where on each await it will return out of the function and then when called again it will go to the next part of the switch. This is basically because async/await gets transpiled to a generator and generators get transpiled to this looping switch thing.

The same effect can be enabled through enabling the following two(1, 2) babel plugins in k6 (by uncommenting the lines). This though (from my not so in-depth testing) seems to not pass most tests, actually the only ones it seems to pass is the one that expect an error, which isn't really ... useful and some very basic ones (actually a lot of Promise ones that currently just get's disabled due to being marked as `async.

This again still requires the import though.

Given that and:

  1. the WIP event loop isn't merged yet, and likely will change
  2. it also isn't used anywhere which means that
  3. using async/await is not going to be ... useful/possible for at least some time and
  4. hopefully goja will get support for some part of that (either generators or async/await altogether)

I do think that these plugins should not be enabled for at least some time (v0.36.0 for example) and instead we should try to make progress on the actual event loop.
Also, the async/await syntax doesn't change any implementation detail (at least in it's the current way with plugins, native support might need some hooks on the client-side/k6) of how we integrate the event loop and return Promise from different parts of the k6 API, so it's not needed to make it work.

tangent: given that generators are functions that can return(yield) and get reinvoked from the place they last returned from/yieled and that await is literally a way for an async function to say - return here and then call the rest of the function when this Promise resolves. I have the expectation that for the most part, both of those can be implemented without any client-side hooks, but I might be wrong, reading the specification for the exact behaviour is ... hard :)

@khawarizmus
Copy link

khawarizmus commented Jan 6, 2022

@mstoykov I am not able to make the async/await syntax to work with K6 and I am not sure what I am missing here.

I am using typescript and my babel config looks like this:

{
  "presets": ["@babel/env", "@babel/typescript"],
  "plugins": [
    "@babel/proposal-class-properties",
    "@babel/proposal-object-rest-spread"
  ]
}

My Webpack config is configured to transpile both typescript and javascript as I have some imported modules that I am transpiling that are not ES5.

it's basically a regex in the exclude property that looks like this /[\\/]node_modules[\\/](?!(@aws-sdk\/client-ssm|proxy-client-ts)[\\/])/

finally my test script looks like this:

import "core-js/stable";
import "regenerator-runtime/runtime";
import { sleep, check, group } from "k6";
import { Options } from "k6/options";
import { Rate } from "k6/metrics";
import http from "k6/http";
import { ProxyBaseURL } from "proxy-client-ts";
import { SSMClient, GetParametersCommand } from "@aws-sdk/client-ssm";

export let options: Options = {
  vus: 1,
  duration: "1s",
  thresholds: {
    // 90% of requests must finish within 3s.
    http_req_duration: ["p(95) < 3000"],
    // http errors should be less than 1% for health checks
    health_req_failed: ["rate<0.01"],
  },
};

const ssmPath = "/auth/asymmetric/private-key/tenant-service";
const client = new SSMClient({ region: "us-west-2" });
const command = new GetParametersCommand({
  Names: [ssmPath],
  WithDecryption: true,
});

// creating a custom rate metrics
const health_failed = new Rate("health_req_failed");

export default async () => {
  console.log("before");
  const keyPairs = await client.send(command);
  console.log("after", keyPairs);
  group("hitting myself endpoint with authentication", function () {
    console.log("inside");
    console.log(keyPairs);
    const url = `${ProxyBaseURL.DEV_US}/proxy-service/rest/api/2/proxy/rest/api/2/myself`;
    const res = http.get(url);
    check(res, {
      "status is 401": () => res.status === 401,
    });
    sleep(1);
  });
};

The code above transpiles well and doesn't throw any errors when running the test. I had to add a few dependencies for that to happen: core-js and regenerator-runtime which are both imported at the top of the test. and node-polyfill-webpack-plugin to polyfill some node related stuff using Webpack.

with that said the test runs but doesn't execute any code after the await keyword. so the above test will only log the following line multiple times:

INFO[0001] before                                        source=console

but it doesn't execute anything after that and the test results are empty

running (01.1s), 0/1 VUs, 424 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  1s

     data_received........: 0 B 0 B/s
     data_sent............: 0 B 0 B/s
     iteration_duration...: avg=2.34ms min=1.97ms med=2.22ms max=6.44ms p(90)=2.7ms p(95)=3.09ms
     iterations...........: 424 391.498933/s
     vus..................: 1   min=1        max=1
     vus_max..............: 1   min=1        max=1

so my question is. how can I make this work and what am I missing here?

@khawarizmus
Copy link

khawarizmus commented Jan 6, 2022

@mstoykov Also to add to this I have tried the exact example provided and the code after the await doesn't execute.

so this:

import "regenerator-runtime/runtime";

const timeout = async (ms) => {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, ms);
  });
};

export default async () => {
  console.log("start");
  const now = Date.now();
  console.log("before wait");
  await timeout(1000);
  console.log(Date.now() - now); // returns 1000
  console.log("end");
};

Will output this:

INFO[0000] start                                         source=console
INFO[0000] before wait                                   source=console

running (00m00.0s), 0/1 VUs, 1 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  00m00.0s/10m0s  1/1 iters, 1 per VU

     data_received........: 0 B 0 B/s
     data_sent............: 0 B 0 B/s
     iteration_duration...: avg=420.11µs min=420.11µs med=420.11µs max=420.11µs p(90)=420.11µs p(95)=420.11µs
     iterations...........: 1   156.201187/s

@mstoykov
Copy link
Contributor

mstoykov commented Jan 6, 2022

Hi @gimyboya,

First, I would like to point out that this isn't officially supported, it just happens to "work"(for some definition of this word) in some cases.

From what you are providing, I would guess there is an exception later on down the line inside a Promise which just aborts the whole iteration. Unfortunately, at the time Promises were added we didn't really have time to integrate them (and it will probably be at least one more release before this happens) so nobody(me specifically) noticed that if a Promise gets an exception inside it and there is no catch it will just not log anything.

When you have some code like

let result = await something():
// more code 

this loosely translates to

something().then((result)={
  // some code
})

Which means that if something() throws an exception, the code after it doesn't get executed. Now for some reason while the exception does bubble up and you can have try/catch to catch it if it bubbles to the top it doesn't actually abort the iteration with it - it gets swallowed instead.
There is an issue with the runtime which seems to be it, but I can't be sure. I also tried to look at the generated code and the runtime to find what is happening, but unfortunately couldn't. I doubt that would lend itself to some okay solution either way, but you are welcome to try and report back. ;)

So far, there are two "solutions":

  1. Have a try/catch block and hope that the above issue applies only for the top async function 🤷
  2. You can use the wip/poc event loop branch where at least this is logged. I would like to note that this currently will more or less log for each Promise created that is reject. All current promises are in fact synchronous(as there are all just done in JS) so at the time they are created they are either resolved or rejected before they have a chance to get reject handler added. Also because of the above the exception that is happening will be logged twice, the first time with the real traceback that gets bubbled and once more when it gets swallowed :(.

Hope this helps you at least somewhat, and hopefully this year we will get native async/await support and nobody will need to try to make this work.

p.s. I am also really surprised @aws-sdk manages to be imported. I guess now that it's moduled it's smaller and might even work, but I would expect that it will scale really badly once you have many VUs, although again, it might work really a lot better(with k6) now that it's split in multiple modules 🤷. But I would expect once you see the exception that is happening it will turn out to be something that you will have hard time fixing :(

@mstoykov
Copy link
Contributor

mstoykov commented Jan 6, 2022

@gimyboya

@mstoykov Also to add to this I have tried the exact example provided and the code after the await doesn't execute.

As noted in there this example is ran with the event loop WIP PR which implements setTimeout if you run it without it will throw an exception that setTimeout is undefined which has problems as noted in my previous message

@khawarizmus
Copy link

@mstoykov Thank you for your elaborate explanation. it was indeed an error not caught and currently, the error leads me to another dead end here

@mstoykov
Copy link
Contributor

mstoykov commented Jan 9, 2023

With the merge of #2830 k6 has support for the async/await syntax.

That and the fact that an event loop has been implemented for some time now means that we can close this issue.

The async/await syntax merged is supposed to be released with v0.43.0.

@mstoykov mstoykov closed this as completed Jan 9, 2023
@na-- na-- added this to the v0.43.0 milestone Jan 9, 2023
@GrayedFox
Copy link

GrayedFox commented Jan 16, 2023

Wanted to post another possible solution here for keeping things DRY by ferreting away asynchronous code into external methods. Because k6 operates without an event loop (afaik) (edit: this will apparently change very soon) we can of design around this limitation by exporting a separate getter method that returns the results of any asynchronous code you are running (so long as you hit the getter after you execute the async code).

This works because k6 runs all JavaScript it reads synchronously and will wait for asynchronous code to complete before continuing execution, but it won't delay (nor does it really "know") the assignment of an externally called method which, if doing something async, will return undefined (or some other value) immediately.

An example:

// your_external_methods.js
import ws from 'k6/ws';

let result = {};

/**
 * Return the results object that the webSocketPayment helper writes to
 */
export function getResult() {
  return result;
}

/**
 * Reset the result object
 */
export function clearResult() {
  result = {};
}

/**
 * Do something async, i.e. web socket stuff
 */
export function asyncStuff() {
  const wsUrl = 'wss://some.thing.dev/ws';
  const maxEvents = 5;
  const maxDuration = 120 * 1000;

  const events = [];

  try {
    result.response = ws.connect(wsUrl, function (socket) {
      socket.on('open', () => {
        socket.send(JSON.stringify({ event: 'start-your-engines'));
      });

      socket.on('message', (data) => {
        events.push(JSON.parse(data));
        //
        if (events.length >= maxEvents) {
          close(socket, 'Got enough events, closing the connection');
        }
      });

      socket.on('close', () => {
        result.events = events;
      });

      socket.on('error', (e) => {
        result.error = e;
      });

      // set timeout in case server doesn't close connection and so tests don't hang
      socket.setTimeout(() => {
        close(socket, 'Timeout reached, closing the connection');
      }, maxDuration);
    });
  } catch (e) {
    result.error = e;
  }
}

Then from inside your test file:

// your_k6_script.js

import { asyncStuff, getResult } from './graphql_payment_helper.js';

export default function () {
  asyncStuff();
  let result = getResult();
  console.log(result); // profit
}

It's a bit messy but just pretend your external module is a class with getters and setters - the external async code acts as the setter - and you only assign the result after calling the set logic - which will wait before assigning the result as expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

7 participants