Skip to content

Commit

Permalink
shareable array implementation (grafana#1739)
Browse files Browse the repository at this point in the history
This is part of grafana#532

This only implements a shareable array that is generated through a callback.
This way any additional processing can be done by any js code once and then the result will be shared between VUs in a readonly fashion.
  • Loading branch information
mstoykov authored and salem84 committed Feb 3, 2021
1 parent 043f299 commit 4be242e
Show file tree
Hide file tree
Showing 8 changed files with 458 additions and 5 deletions.
7 changes: 4 additions & 3 deletions js/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,10 @@ func (b *Bundle) instantiate(logger logrus.FieldLogger, rt *goja.Runtime, init *
// TODO: get rid of the unused ctxPtr, use a real external context (so we
// can interrupt), build the common.InitEnvironment earlier and reuse it
initenv := &common.InitEnvironment{
Logger: logger,
FileSystems: init.filesystems,
CWD: init.pwd,
SharedObjects: init.sharedObjects,
Logger: logger,
FileSystems: init.filesystems,
CWD: init.pwd,
}
ctx := common.WithInitEnv(context.Background(), initenv)
*init.ctxPtr = common.WithRuntime(ctx, rt)
Expand Down
33 changes: 33 additions & 0 deletions js/common/initenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ package common
import (
"net/url"
"path/filepath"
"sync"

"github.com/sirupsen/logrus"
"github.com/spf13/afero"
Expand All @@ -37,6 +38,7 @@ type InitEnvironment struct {
// TODO: add RuntimeOptions and other properties, goja sources, etc.
// ideally, we should leave this as the only data structure necessary for
// executing the init context for all JS modules
SharedObjects *SharedObjects
}

// GetAbsFilePath should be used to access the FileSystems, since afero has a
Expand All @@ -60,3 +62,34 @@ func (ie *InitEnvironment) GetAbsFilePath(filename string) string {
}
return filename
}

// SharedObjects is a collection of general store for objects to be shared. It is mostly a wrapper
// around map[string]interface with a lock and stuff.
// The reason behind not just using sync.Map is that it still needs a lock when we want to only call
// the function constructor if there is no such key at which point you already need a lock so ...
type SharedObjects struct {
data map[string]interface{}
l sync.Mutex
}

// NewSharedObjects returns a new SharedObjects ready to use
func NewSharedObjects() *SharedObjects {
return &SharedObjects{
data: make(map[string]interface{}),
}
}

// GetOrCreateShare returns a shared value with the given name or sets it's value whatever
// createCallback returns and returns it.
func (so *SharedObjects) GetOrCreateShare(name string, createCallback func() interface{}) interface{} {
so.l.Lock()
defer so.l.Unlock()

value, ok := so.data[name]
if !ok {
value = createCallback()
so.data[name] = value
}

return value
}
5 changes: 4 additions & 1 deletion js/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,20 @@ func getSimpleRunner(tb testing.TB, filename, data string, opts ...interface{})
var (
fs = afero.NewMemMapFs()
rtOpts = lib.RuntimeOptions{CompatibilityMode: null.NewString("base", true)}
logger = testutils.NewLogger(tb)
)
for _, o := range opts {
switch opt := o.(type) {
case afero.Fs:
fs = opt
case lib.RuntimeOptions:
rtOpts = opt
case *logrus.Logger:
logger = opt
}
}
return New(
testutils.NewLogger(tb),
logger,
&loader.SourceData{
URL: &url.URL{Path: filename, Scheme: "file"},
Data: []byte(data),
Expand Down
5 changes: 4 additions & 1 deletion js/initcontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type InitContext struct {
compatibilityMode lib.CompatibilityMode

logger logrus.FieldLogger

sharedObjects *common.SharedObjects
}

// NewInitContext creates a new initcontext with the provided arguments
Expand All @@ -85,6 +87,7 @@ func NewInitContext(
programs: make(map[string]programWithSource),
compatibilityMode: compatMode,
logger: logger,
sharedObjects: common.NewSharedObjects(),
}
}

Expand All @@ -110,6 +113,7 @@ func newBoundInitContext(base *InitContext, ctxPtr *context.Context, rt *goja.Ru
programs: programs,
compatibilityMode: base.compatibilityMode,
logger: base.logger,
sharedObjects: base.sharedObjects,
}
}

Expand Down Expand Up @@ -162,7 +166,6 @@ func (i *InitContext) requireFile(name string) (goja.Value, error) {

if pgm.pgm == nil {
// Load the sources; the loader takes care of remote loading, etc.
// TODO: don't use the Global logger
data, err := loader.Load(i.logger, i.filesystems, fileURL, name)
if err != nil {
return goja.Undefined(), err
Expand Down
1 change: 1 addition & 0 deletions js/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
_ "github.com/loadimpact/k6/js/modules/k6"
_ "github.com/loadimpact/k6/js/modules/k6/crypto"
_ "github.com/loadimpact/k6/js/modules/k6/crypto/x509"
_ "github.com/loadimpact/k6/js/modules/k6/data"
_ "github.com/loadimpact/k6/js/modules/k6/encoding"
_ "github.com/loadimpact/k6/js/modules/k6/grpc"
_ "github.com/loadimpact/k6/js/modules/k6/http"
Expand Down
95 changes: 95 additions & 0 deletions js/modules/k6/data/data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
*
* k6 - a next-generation load testing tool
* Copyright (C) 2020 Load Impact
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package data

import (
"context"

"github.com/dop251/goja"
"github.com/loadimpact/k6/js/common"
"github.com/loadimpact/k6/js/internal/modules"
"github.com/loadimpact/k6/lib"
"github.com/pkg/errors"
)

type data struct{}

func init() {
modules.Register("k6/data", new(data))
}

const sharedArrayNamePrefix = "k6/data/SharedArray."

// XSharedArray is a constructor returning a shareable read-only array
// indentified by the name and having their contents be whatever the call returns
func (d *data) XSharedArray(ctx context.Context, name string, call goja.Callable) (goja.Value, error) {
if lib.GetState(ctx) != nil {
return nil, errors.New("new SharedArray must be called in the init context")
}

initEnv := common.GetInitEnv(ctx)
if initEnv == nil {
return nil, errors.New("missing init environment")
}
if len(name) == 0 {
return nil, errors.New("empty name provided to SharedArray's constructor")
}

name = sharedArrayNamePrefix + name
value := initEnv.SharedObjects.GetOrCreateShare(name, func() interface{} {
return getShareArrayFromCall(common.GetRuntime(ctx), call)
})
array, ok := value.(sharedArray)
if !ok { // TODO more info in the error?
return nil, errors.New("wrong type of shared object")
}

return array.wrap(&ctx, common.GetRuntime(ctx)), nil
}

func getShareArrayFromCall(rt *goja.Runtime, call goja.Callable) sharedArray {
gojaValue, err := call(goja.Undefined())
if err != nil {
common.Throw(rt, err)
}
obj := gojaValue.ToObject(rt)
if obj.ClassName() != "Array" {
common.Throw(rt, errors.New("only arrays can be made into SharedArray")) // TODO better error
}
arr := make([]string, obj.Get("length").ToInteger())

// We specifically use JSON.stringify here as we need to use JSON.parse on the way out
// it also has the benefit of needing only one loop and being more JS then using golang's json
cal, err := rt.RunString(`(function(input, output) {
for (var i = 0; i < input.length; i++) {
output[i] = JSON.stringify(input[i])
}
})`)
if err != nil {
common.Throw(rt, err)
}
newCall, _ := goja.AssertFunction(cal)
_, err = newCall(goja.Undefined(), gojaValue, rt.ToValue(arr))
if err != nil {
common.Throw(rt, err)
}
return sharedArray{arr: arr}
}
135 changes: 135 additions & 0 deletions js/modules/k6/data/share.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
*
* k6 - a next-generation load testing tool
* Copyright (C) 2020 Load Impact
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package data

import (
"context"

"github.com/dop251/goja"
"github.com/loadimpact/k6/js/common"
)

// TODO fix it not working really well with setupData or just make it more broken
// TODO fix it working with console.log
type sharedArray struct {
arr []string
}

func (s sharedArray) wrap(ctxPtr *context.Context, rt *goja.Runtime) goja.Value {
cal, err := rt.RunString(arrayWrapperCode)
if err != nil {
common.Throw(rt, err)
}
call, _ := goja.AssertFunction(cal)
wrapped, err := call(goja.Undefined(), rt.ToValue(common.Bind(rt, s, ctxPtr)))
if err != nil {
common.Throw(rt, err)
}

return wrapped
}

func (s sharedArray) Get(index int) (interface{}, error) {
if index < 0 || index >= len(s.arr) {
return goja.Undefined(), nil
}

// we specifically use JSON.parse to get the json to an object inside as otherwise we won't be
// able to freeze it as goja doesn't let us unless it is a pure goja object and this is the
// easiest way to get one.
return s.arr[index], nil
}

func (s sharedArray) Length() int {
return len(s.arr)
}

/* This implementation is commented as with it - it is harder to deepFreeze it with this implementation.
type sharedArrayIterator struct {
a *sharedArray
index int
}
func (sai *sharedArrayIterator) Next() (interface{}, error) {
if sai.index == len(sai.a.arr)-1 {
return map[string]bool{"done": true}, nil
}
sai.index++
var tmp interface{}
if err := json.Unmarshal(sai.a.arr[sai.index], &tmp); err != nil {
return goja.Undefined(), err
}
return map[string]interface{}{"value": tmp}, nil
}
func (s sharedArray) Iterator() *sharedArrayIterator {
return &sharedArrayIterator{a: &s, index: -1}
}
*/

const arrayWrapperCode = `(function(val) {
function deepFreeze(o) {
Object.freeze(o);
if (o === undefined) {
return o;
}
Object.getOwnPropertyNames(o).forEach(function (prop) {
if (o[prop] !== null
&& (typeof o[prop] === "object" || typeof o[prop] === "function")
&& !Object.isFrozen(o[prop])) {
deepFreeze(o[prop]);
}
});
return o;
};
var arrayHandler = {
get: function(target, property, receiver) {
switch (property){
case "length":
return target.length();
case Symbol.iterator:
return function(){
var index = 0;
return {
"next": function() {
if (index >= target.length()) {
return {done: true}
}
var result = {value: deepFreeze(JSON.parse(target.get(index)))};
index++;
return result;
}
}
}
}
var i = parseInt(property);
if (isNaN(i)) {
return undefined;
}
return deepFreeze(JSON.parse(target.get(i)));
}
};
return new Proxy(val, arrayHandler);
})`
Loading

0 comments on commit 4be242e

Please sign in to comment.