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

Allow for adding objects to storage without round-tripping through JSON #4708

Closed
willbeason opened this issue May 25, 2022 · 16 comments · Fixed by #5015
Closed

Allow for adding objects to storage without round-tripping through JSON #4708

willbeason opened this issue May 25, 2022 · 16 comments · Fixed by #5015
Assignees

Comments

@willbeason
Copy link
Member

What part of OPA would you like to see improved?

Motivating issue: open-policy-agent/gatekeeper#2060

When adding data to the in-memory OPA storage object, inmem.store.Write round-trips the incoming object through JSON to ensure the object may be validly added to OPA storage. Adding an object which cannot be serialized to JSON could cause all sorts of misbehaviors when running OPA policies.

From inmem.store.Write:

func (db *store) Write(_ context.Context, txn storage.Transaction, op storage.PatchOp, path storage.Path, value interface{}) error {
	underlying, err := db.underlying(txn)
	if err != nil {
		return err
	}
	val := util.Reference(value)
	if err := util.RoundTrip(val); err != nil { // <-- this function
		return err
	}
	return underlying.Write(op, path, *val)
}

Unfortunately, this round-tripping is very slow compared to the rest of what needs to be done to store/update the new data.

Benchmark with round-tripping:

~/frameworks/constraint$ go test ./pkg/client/drivers/local/ --run=NONE --bench=. --benchtime=10s --cpuprofile=cpu.out
goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
BenchmarkStorages_AddData-16    	   29972	    334523 ns/op
PASS
ok  	github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local	17.951s

Benchmark without round-tripping (with the check commented out):

~/frameworks/constraint$ go test ./pkg/client/drivers/local/ --run=NONE --bench=. --benchtime=10s --cpuprofile=cpu.out
goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
BenchmarkStorages_AddData-16    	 4121306	      2765 ns/op
PASS
ok  	github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local	15.388s

image

In the benchmark >99% of the time spend adding new objects is in round-tripping through JSON.

Describe the ideal solution

Ideally, there would be some way for callers to add data to inmem.store without needing to do this round-tripping. Callers may be able to do this validation themselves more cheaply, or are operating in an environment where all objects by definition are serializable to JSON (e.g. the incoming request was JSON, and that is the object to be put into OPA storage) and so this validation is unnecessary.

This option could take many forms:

  • An option on inmem.store at creation time to not round-trip objects through JSON
  • An option on Write() to skip this check
  • A separate function WriteNoRoundTrip() which is the same as Write() but does not include the check

Additional Context

Per the motivating bug - the issue which (at least) one user of Gatekeeper is having is that the speed of these writes means that it takes a long time for queries to run since it takes too long to acquire a read lock for the Storage object (which is required for Query to run).

@willbeason
Copy link
Member Author

@willbeason
Copy link
Member Author

For callers who have objects with a DeepCopy-like method defined, the savings are ~90% instead of ~99% if the caller is not able to guarantee the object will remain unmutated and so need to deep copy the object before calling inmem.store.Write:

~/frameworks/constraint$ go test ./pkg/client/drivers/local/ --run=NONE --bench=. --benchtime=10s --cpuprofile=cpu.out
goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
BenchmarkStorages_AddData-16    	  334730	     33309 ns/op
PASS
ok  	github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local	12.077s

(This benchmark is for a function that adds a .DeepCopy() to the object added to Rego storage before passing to the modified form of inmem.store.Write which doesn't round-trip through JSON)

@willbeason
Copy link
Member Author

Thinking further a "good enough" solution for us would be to do the round-tripping validation without needing to acquire the write lock on the storage. That would enable both the validation and reduce the time that the lock needs to be held by 99%.

The implementation for that might be more complex, though.

@willbeason willbeason self-assigned this May 25, 2022
willbeason pushed a commit to willbeason/opa that referenced this issue May 25, 2022
Add option to inmem.store which allows disabling the round-tripping
through JSON when adding data to the store.

This option is intended for callers who can guarantee the objects they
pass to Write are JSON objects, and have properly ensured the object
will be only be accessed by store once added.

See open-policy-agent#4708

Signed-off-by: Will Beason <willbeason@google.com>
@srenatus
Copy link
Contributor

srenatus commented May 26, 2022

IIRC we're doing the roundtrip to support feeding arbitrary structs into the storage and having them converted to native golang types (so, a struct { Foo, Bar string } becomes a map[string]interface{} with keys "Foo" and "Bar", unless there are json struct tags). It seemed like a good idea at the time to do it like this.

Another approach to address the problem would be to make util.RoundTrip cleverer: traverse the passed-in element's structure, only to the JSON marshal/unmarshal dance for leaves that are not native types. At the core, I'd imagine something like

switch x.(type) {
    case string, int, float: // ... more native types
         // nothing
    case map[string]interface{}, []interface{}:
         // recurse for keys
    default: // must be struct
         // do the json roundtrip
}

If what comes in is a native type, I don't think (but we should verify) that the overhead would be as bad as what we have now.

I suppose that way, we'd improve the situations also for those folks unable to disable the roundtrip on write (#4709), namely OPA-as-server users. (Update: Uhr realized that this point is moot, the server runtime could disable the roundtrip, too, as there's no need for it.)

@anderseknert
Copy link
Member

This ticket is also interesting on the topic of JSON serialization/deserialization performance. Although obviously, nothing beats skipping it in the first place :) Do you have any experience working with that library, @willbeason? Would be interesting to see what difference it makes to the numbers you presented!

@willbeason
Copy link
Member Author

The other side effect you get of round-tripping through JSON is an implicit deepcopy of the entire struct. So if we only round-tripped subfields which were not native JSON types, we would no longer be deepcopying objects put into OPA storage.

(The rest of this exclusively deals with JSON-native types)

My guess is what srenatus@ proposes would have similar performance gains to the DeepCopy. Whether we actually copy the data into a new object likely has little CPU performance impact (compared to not doing it but still traversing the tree) - but of course you'd just be holding on to more memory after. In either case, it's much faster/cheaper to do this than passing everything through JSON.

@willbeason
Copy link
Member Author

From looking at open PRs and Issues - unfortunately json-iterator doesn't look like it is actively maintained.

@willbeason
Copy link
Member Author

Confirmed it is ~10x faster to do what @srenatus suggests:

~/frameworks/constraint$ go test ./pkg/client/drivers/local/ --run=NONE --bench=.
goos: linux
goarch: amd64
pkg: github.com/open-policy-agent/frameworks/constraint/pkg/client/drivers/local
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
BenchmarkStorages_AddData/structured-16         	    2695	    385944 ns/op
BenchmarkStorages_AddData/jsonified-16          	   35253	     32523 ns/op

@willbeason
Copy link
Member Author

With the JSONify deepcopy, it is only 4x faster. It is possible that won't fully resolve our issue so I'll send a follow-up to reduce the time the write-lock is held.

@anderseknert
Copy link
Member

Thanks @willbeason, I had not checked that library since the ticket was created. Not sure to what extent it matters, unless there's some known issue that would e.g. be different from what we have today. I mean, the JSON library in the stdlib is probably not seeing a ton of updates either. Definitely agree that it would be nicer to see it actively maintained though.

@stale
Copy link

stale bot commented Jun 26, 2022

This issue has been automatically marked as inactive because it has not had any activity in the last 30 days.

@stale stale bot added the inactive label Jun 26, 2022
@maxsmythe
Copy link
Contributor

@srenatus it looks like the solution we're going with here is #4709 . You mentioned adopting the change on the next release cycle. Do you know what the timeline for that is?

@stale stale bot removed the inactive label Aug 3, 2022
@srenatus
Copy link
Contributor

srenatus commented Aug 4, 2022

It didn't make it for 0.43.0, but I haven't forgotten about it. Let's aim for 0.44.0.

@maxsmythe
Copy link
Contributor

Thanks!

srenatus pushed a commit to srenatus/opa that referenced this issue Aug 18, 2022
Add option to inmem.store which allows disabling the round-tripping
through JSON when adding data to the store.

This option is intended for callers who can guarantee the objects they
pass to Write are JSON objects, and have properly ensured the object
will be only be accessed by store once added.

Fixes open-policy-agent#4708.

This is continuance of open-policy-agent#4709,
adding these bits:

* storage/inmem: backwards-compat nitpicks, test adaptations

  I might have overshot here, but adding variable-length function parameters
  is not a backwards-compatible move. Concretely, if you had been using code like

      var x func() storage.Store = inmem.New

  going from New() to New(...Opts) would break it.

* storage/inmem: use it where possible without roundtrip

* storage/inmem: deal with nil map

  It looks like this is something the roundtrip had guarded us from.
  Now, we'll explicitly check this.

  This came up when running the bundle tests with roundtripping disabled.

* loader: add StoreWithOpts convenience method

Co-authored-by: Will Beason <willbeason@google.com>
Co-authored-by: Philip Conrad <conradp@chariot-chaser.net>
Signed-off-by: Stephan Renatus <stephan.renatus@gmail.com>
srenatus added a commit that referenced this issue Aug 18, 2022
Add option to inmem.store which allows disabling the round-tripping
through JSON when adding data to the store.

This option is intended for callers who can guarantee the objects they
pass to Write are JSON objects, and have properly ensured the object
will be only be accessed by store once added.

Fixes #4708.

This is continuance of #4709,
adding these bits:

* storage/inmem: backwards-compat nitpicks, test adaptations

  I might have overshot here, but adding variable-length function parameters
  is not a backwards-compatible move. Concretely, if you had been using code like

      var x func() storage.Store = inmem.New

  going from New() to New(...Opts) would break it.

* storage/inmem: use it where possible without roundtrip

* storage/inmem: deal with nil map

  It looks like this is something the roundtrip had guarded us from.
  Now, we'll explicitly check this.

  This came up when running the bundle tests with roundtripping disabled.

* loader: add StoreWithOpts convenience method

Co-authored-by: Will Beason <willbeason@google.com>
Co-authored-by: Philip Conrad <conradp@chariot-chaser.net>
Signed-off-by: Stephan Renatus <stephan.renatus@gmail.com>
@srenatus
Copy link
Contributor

@maxsmythe If no unexpected regressions come up, this is expected to come out with 0.44.0.

@maxsmythe
Copy link
Contributor

Thanks!

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

Successfully merging a pull request may close this issue.

4 participants