Skip to content

Commit

Permalink
Add abortTest() helper function
Browse files Browse the repository at this point in the history
Closes grafana#1001
This adds abortTest() helper function to the k6 module. This function when
called inside a script it will

- stop the whole test run and k6 will exit with 107 status code
- stops immediately the VU that called it  and no more iterations are started
- make sure the teardown() is called
- the engine run status is 7 (RunStatusAbortedScriptError)

`(*goja.Runtime).Interrupt` is used for halting script execution and capturing
stack traces for better error message of what is happening with the script.

We introduce InterruptError which is used with `(*goja.Runtime).Interrupt` to
identify interrupts emitted by abortTest(). This way we use special handling of
this type.

Example script is

```js
import { abortTest, sleep } from 'k6';

export default function () {
    // We abort the test on second iteration
    if (__ITER == 1) {
        abortTest();
    }
    sleep(1);
}

export function teardown() {
    console.log('This function will be called even when we abort script');
}
```

abortTest() can be called in both default and setup functions, however you can't use it
in the init context

The following script will fail with the error
```
ERRO[0000] Using abortTest() in the init context is not supported at (...path to the script )init.js:13:43(34)
```

```js
import {
    abortTest
} from 'k6';

abortTest();

export function setup() {
}

export default function () {
    // ... some test logic ...
    console.log('mayday, mayday');
}
```

You can customize the reason for abortTest() by passing values to the function

```js
abortTest("Exceeded expectations");
```

Will emit `"Exceeded expectations"` on the logs
  • Loading branch information
gernest committed Mar 22, 2021
1 parent 9d973f5 commit b8defae
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 4 deletions.
4 changes: 4 additions & 0 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
"github.com/loadimpact/k6/core"
"github.com/loadimpact/k6/core/local"
"github.com/loadimpact/k6/js"
"github.com/loadimpact/k6/js/common"
"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/lib/consts"
"github.com/loadimpact/k6/loader"
Expand All @@ -63,6 +64,7 @@ const (
invalidConfigErrorCode = 104
externalAbortErrorCode = 105
cannotStartRESTAPIErrorCode = 106
abortedByScriptErrorCode = 107
)

// TODO: fix this, global variables are not very testable...
Expand Down Expand Up @@ -345,6 +347,8 @@ func getExitCodeFromEngine(err error) ExitCode {
default:
return ExitCode{error: err, Code: genericTimeoutErrorCode}
}
case *common.InterruptError:
return ExitCode{error: errors.New("Engine error"), Code: abortedByScriptErrorCode, Hint: e.Reason}
default:
//nolint:golint
return ExitCode{error: errors.New("Engine error"), Code: genericEngineErrorCode, Hint: err.Error()}
Expand Down
7 changes: 6 additions & 1 deletion core/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/sirupsen/logrus"
"gopkg.in/guregu/null.v3"

"github.com/loadimpact/k6/js/common"
"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/lib/metrics"
"github.com/loadimpact/k6/output"
Expand Down Expand Up @@ -246,7 +247,11 @@ func (e *Engine) startBackgroundProcesses(
case err := <-runResult:
if err != nil {
e.logger.WithError(err).Debug("run: execution scheduler returned an error")
e.setRunStatus(lib.RunStatusAbortedSystem)
status := lib.RunStatusAbortedSystem
if common.IsInteruptError(err) {
status = lib.RunStatusAbortedScriptError
}
e.setRunStatus(status)
} else {
e.logger.Debug("run: execution scheduler terminated")
e.setRunStatus(lib.RunStatusFinished)
Expand Down
14 changes: 12 additions & 2 deletions core/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/sirupsen/logrus"

"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/lib/executor"
"github.com/loadimpact/k6/stats"
"github.com/loadimpact/k6/ui/pb"
)
Expand Down Expand Up @@ -379,8 +380,14 @@ func (e *ExecutionScheduler) Run(globalCtx, runCtx context.Context, engineOut ch
// Start all executors at their particular startTime in a separate goroutine...
logger.Debug("Start all executors...")
e.state.SetExecutionStatus(lib.ExecutionStatusRunning)

// We are using this context to allow lib.Executor implementations to cancel
// this context effectively stopping all executions.
//
// This is for addressing abortTest()
execCtx := executor.Context(runSubCtx)
for _, exec := range e.executors {
go e.runExecutor(runSubCtx, runResults, engineOut, exec)
go e.runExecutor(execCtx, runResults, engineOut, exec)
}

// Wait for all executors to finish
Expand All @@ -407,7 +414,10 @@ func (e *ExecutionScheduler) Run(globalCtx, runCtx context.Context, engineOut ch
return err
}
}

if err := executor.CancelReason(execCtx); err != nil {
// The execution was interupted
return err
}
return firstErr
}

Expand Down
25 changes: 25 additions & 0 deletions js/common/interupt_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package common

type InterruptError struct {
Reason string
}

func (i InterruptError) Error() string {
return i.Reason
}

var AbortTest = &InterruptError{
Reason: "abortTest() was called in a script",
}

var AbortTestInitContext = &InterruptError{
Reason: "Using abortTest() in the init context is not supported",
}

func IsInteruptError(err error) bool {
if err == nil {
return false
}
_, ok := err.(*InterruptError)
return ok
}
18 changes: 18 additions & 0 deletions js/modules/k6/k6.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ func (*K6) Group(ctx context.Context, name string, fn goja.Callable) (goja.Value
return ret, err
}

func (*K6) AbortTest(ctx context.Context, extra ...goja.Value) {
state := lib.GetState(ctx)
rt := common.GetRuntime(ctx)
if state == nil {
rt.Interrupt(common.AbortTestInitContext)
return
}
e := *common.AbortTest
if len(extra) > 0 {
var m string
for _, v := range extra {
m += v.String()
}
e.Reason = m
}
rt.Interrupt(&e)
}

func (*K6) Check(ctx context.Context, arg0, checks goja.Value, extras ...goja.Value) (bool, error) {
state := lib.GetState(ctx)
if state == nil {
Expand Down
9 changes: 8 additions & 1 deletion js/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -654,9 +654,16 @@ func (u *ActiveVU) RunOnce() error {
// Shouldn't happen; this is validated in cmd.validateScenarioConfig()
panic(fmt.Sprintf("function '%s' not found in exports", u.Exec))
}

// Call the exported function.
_, isFullIteration, totalTime, err := u.runFn(u.RunContext, true, fn, u.setupData)
if err != nil {
if x, ok := err.(*goja.InterruptedError); ok {
if v, ok := x.Value().(*common.InterruptError); ok {
v.Reason = x.Error()
err = v
}
}
}

// If MinIterationDuration is specified and the iteration wasn't canceled
// and was less than it, sleep for the remainder
Expand Down
7 changes: 7 additions & 0 deletions js/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,13 @@ func TestInitContextForbidden(t *testing.T) {
exports.default = function() { console.log("p"); }`,
k6.ErrCheckInInitContext.Error(),
},
{
"abortTest",
`var abortTest = require("k6").abortTest;
abortTest();
exports.default = function() { console.log("p"); }`,
common.AbortTestInitContext.Error(),
},
{
"group",
`var group = require("k6").group;
Expand Down
57 changes: 57 additions & 0 deletions lib/executor/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"github.com/sirupsen/logrus"

"github.com/loadimpact/k6/js/common"
"github.com/loadimpact/k6/lib"
"github.com/loadimpact/k6/lib/types"
"github.com/loadimpact/k6/ui/pb"
Expand Down Expand Up @@ -74,6 +75,58 @@ func validateStages(stages []Stage) []error {
return errors
}

// cancelKey is the key used to store the cancel function for the context of an
// executor. This is a work around to avoid excessive changes for the abolity of
// nested functions to cancel the passed context
type cancelKey struct{}

type cancelExec struct {
cancel context.CancelFunc
reason error
}

// Context returns context.Context that can be cancelled by calling
// CancelExecutorContext. Use this to initialize context that will be passed to
// executors.
//
// This allows executors to globally halt any executions that uses this context.
// Example use case is when a script calls abortTest()
func Context(ctx context.Context) context.Context {
ctx, cancel := context.WithCancel(ctx)
return context.WithValue(ctx, cancelKey{}, &cancelExec{cancel: cancel})
}

// CancelExecutorContext cancels executor context found in ctx, ctx can be a
// child of a context that was created with Context function.
func CancelExecutorContext(ctx context.Context, err error) {
if x := ctx.Value(cancelKey{}); x != nil {
v := x.(*cancelExec)
v.reason = err
v.cancel()
}
}

// CancelReason returns a reason the executor context was cancelled. This will
// return nil if ctx is not an executor context(ctx or any of its parents was
// never created by Context function).
func CancelReason(ctx context.Context) error {
if x := ctx.Value(cancelKey{}); x != nil {
v := x.(*cancelExec)
return v.reason
}
return nil
}

func handleInterupt(ctx context.Context, err error) bool {
if err != nil {
if common.IsInteruptError(err) {
CancelExecutorContext(ctx, err)
return false
}
}
return false
}

// getIterationRunner is a helper function that returns an iteration executor
// closure. It takes care of updating the execution state statistics and
// warning messages. And returns whether a full iteration was finished or not
Expand All @@ -96,6 +149,10 @@ func getIterationRunner(
return false
default:
if err != nil {
if handleInterupt(ctx, err) {
executionState.AddInterruptedIterations(1)
return false
}
if s, ok := err.(fmt.Stringer); ok {
// TODO better detection for stack traces
// TODO don't count this as a full iteration?
Expand Down

0 comments on commit b8defae

Please sign in to comment.