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

shareable array implementation #1739

Merged
merged 10 commits into from
Jan 11, 2021
7 changes: 4 additions & 3 deletions js/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,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) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain (and maybe add as a code comment here) why we're encoding the data to JSON on the JS side? JSON.stringify() does something magical that json.Marshal() doesn't? Or does this save us from utf8<->utf16 conversions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The commit comment explains it :P. Using JSON.parse was the easiest way to get a goja object thing that can then be frozen as otherwise json.Decode gets you a golang map which ... just isn't a goja object. You can make it into a value but it still the golang map underneath and doesn't let itself be frozen (the goja author actually commented on that).
So at that point, I had the question - do I use two different libraries to do the encode/decode or not, and went with "not" ;).

They have some ... interesting behavior differences around stuff like functions and other strange types.

But IMO it is much better to be as JS like as possible - explaining that what we do is JSON.parse/JSON.stringify a value instead of json.Encode/Decode in golang and that is why your strange value doesn't work will be again easier for users. Also possibly make it possible for them to make it work.

Another benefit is this way I iterate only once here instead of twice, or once but use string indexes and Object.Get 😱.

Copy link
Member

Choose a reason for hiding this comment

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

Please add a code comment with that, since I doubt anyone will read the commit description in the future and might try to "optimize" it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's the problem .. people don't read git commits 😭

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