-
Notifications
You must be signed in to change notification settings - Fork 452
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
Pass user information with the op to remote clients. #513
Conversation
lib/agent.js
Outdated
@@ -260,7 +260,9 @@ Agent.prototype._sendOp = function(collection, id, op) { | |||
if ('op' in op) message.op = op.op; | |||
if (op.create) message.create = op.create; | |||
if (op.del) message.del = true; | |||
|
|||
if(op.m && this.backend.options && this.backend.options.opsOptions && this.backend.options.opsOptions.metadata) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not so sure about this all-or-nothing approach to toggling metadata. There's a very good chance there's some metadata you may or may not want to send back to users for security reasons.
This may be collection-dependent. It may even be doc-dependent, I suppose. Or perhaps consumers should be able to project the metadata, etc.
My gut feel for this is that it should be settable in the middleware like suppressPublish
or saveMilestoneSnapshot
, so that consumers can be as granular as they like about metadata.
I'd probably expect the API to look something like:
backend.use('commit', (context, next) => {
// Can set some arbitrarily complex condition
if (context.collection === 'foo') {
// Only give us the `bar` property on the metadata
context.opMetadataProjection = {bar: true};
} else {
// Otherwise, context.opMetadataProjection defaults to `null`,
// and we send back no metadata
}
next();
});
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you mean. Let me rework my pull request with this concept. Thank you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One question, is there a way to reach context
from Agent.prototype._sendOp
? If not, we'd need to set context.agent.opMetadataProjection
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why? I'm not sure I'm clear on the scope here: are we trying to get metadata to the client for newly-submitted ops, or for all ops?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The confusion may be due to my lack of familiarity with the code. Please forgive me. It looks like in order to be able to generate an outbound projection inside Agent.prototype._sendOp
, it needs to have access to the projection configuration set in context.opMetadataProjection
. Are you suggesting to always message.m = Object.assign({}, op.m);
inside Agent.prototype._sendOp
and handle the actual projection inside commit/submit ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, that's exactly what I'm suggesting. I think that should hopefully solve your issue? Let me know if I've misunderstood.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@alecgibson , you just simplified my code tenfold. Thanks. It now looks like this:
wss.on('connection', function(ws, req) {
let metadata = {};
try {
let userInfo = JSON.parse(Buffer.from(req.headers["x-oidc-user-info"], 'base64').toString('ascii'));
metadata.username = userInfo.preferred_username;
} catch(e) {
console.log("Failed to parse user info", req.headers["x-oidc-user-info"], e);
}
console.log("wss connected", metadata);
backend.use('submit', (context, next) => {
Object.assign(context.op.m, metadata);
next();
});
let stream = new WebSocketJSONStream(ws);
backend.listen(stream);
});
lib/backend.js
Outdated
@@ -22,6 +22,8 @@ function Backend(options) { | |||
emitter.EventEmitter.call(this); | |||
|
|||
if (!options) options = {}; | |||
this.options = options; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably shouldn't do this, because:
- it's inconsistent to previous behaviour
- consumers might expect that updating properties in
options
will lead to altered behaviour on other options (which it won't)
lib/client/doc.js
Outdated
// NB: If we need to add another argument to this event, we should consider | ||
// the fact that the 'op' event has op.src as its 3rd argument | ||
this.emit('before op batch', op.op, source); | ||
this.emit('before op batch', op.op, source, op.m); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we do this, we should probably add op.src
for consistency with the 'op'
event arguments. Same goes for all the others like this.
lib/client/doc.js
Outdated
@@ -620,28 +621,28 @@ Doc.prototype._otApply = function(op, source) { | |||
if (transformErr) return this._hardRollback(transformErr); | |||
} | |||
// Apply the individual op component | |||
this.emit('before op', componentOp.op, source, op.src); | |||
this.emit('before op', componentOp.op, source, op.src, op.m); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I regret not having wrapped op.src
in some sort of "other" metadata object. Maybe it's time to do that now so we don't keep inflating this list of arguments indefinitely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would you be open to this signature, where the 3rd param is op.src, and 4th param is the whole op? This way whoever needs whatever from op have access to that whatever?
this.emit('before op', componentOp.op, source, op.src, op);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think @ericyhwang had suggested something similar to this in the past. I'm not necessarily against it, although I'd potentially still bury it in a wrapper object in case we need more arguments
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have adjusted the PR to align emit signatures and wrap the op.
lib/client/doc.js
Outdated
@@ -587,9 +587,10 @@ Doc.prototype._otApply = function(op, source) { | |||
); | |||
} | |||
|
|||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Any reason in particular for extra whitespace?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm... How did this get in here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we also get some tests for this, please?
lib/submit-request.js
Outdated
@@ -171,7 +171,6 @@ SubmitRequest.prototype.commit = function(callback) { | |||
var op = request.op; | |||
op.c = request.collection; | |||
op.d = request.id; | |||
op.m = undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't quite the API I'd suggested. By default, all ops will be returned with metadata, which breaks our current default behaviour. I think we should specify a mapping in middleware, which we apply here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's talk this through. Current default behavior is no metadata
. What we want is to add metadata when explicitly requested in middleware, like so:
backend.use('submit', (context, next) => {
Object.assign(context.op.m, {foor: bar});
next();
});
The only metadata set in the entirety of the code is here.
SubmitRequest.prototype._addOpMeta = function() {
this.op.m = {
ts: this.start
};
if (this.op.create) {
// Consistently store the full URI of the type, not just its short name
this.op.create.type = ot.normalizeType(this.op.create.type);
}
};
Considering that we always remove metadata, and never really use it, maybe the only thing we need to do to maintain full backward compatibility is to get rid of this.op.m.ts
assignment in SubmitRequest.prototype._addOpMeta
? It doesn't seem to be used anywhere. And if someone needs a ts
they can explicitly assign it in middleware?
backend.use('submit', (context, next) => {
Object.assign(context.op.m, {foor: bar, ts: Date.now()});
next();
});
This also means that no special mapping logic is required, since the assignment in the middleware serves the same purpose.
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I was wrong. Found ts
being used here.
function filterOpsInPlaceBeforeTimestamp(ops, timestamp) {
if (timestamp === null) {
return;
}
for (var i = 0; i < ops.length; i++) {
var op = ops[i];
var opTimestamp = op.m && op.m.ts;
if (opTimestamp > timestamp) {
ops.length = i;
return;
}
}
}
Need to think a bit more...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other thing that m
is currently used for is attaching metadata to ops to be persisted in the database. For example, consider this middleware:
backend.use('commit', (context, next) => {
context.op.m.userId = context.agent.custom.userId;
next();
});
The current behaviour is that this metadata is attached to the op, and then submitted to the database (if you're using sharedb-mongo
, you'll see this persisted in your o_XXX
collection as an m
property, alongside the aforementioned ts
). This metadata is then scrubbed before sending it to remote clients (the op.m = undefined
line you've removed).
So although ShareDB internals don't expressly use m
that much, consumers are (myself included!), so broadcasting this metadata to other clients is breaking behaviour, and not necessarily desired.
I don't understand what's wrong with the projection approach I'd suggested?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like we can't avoid adding a projection logic. Your initial suggestion seems to be the best way to go. Just pushed another commit. Presently the usage would look something like this:
backend.use('connect', (context, next) => {
Object.assign(context.agent.custom, {foo:bar});
next();
});
backend.use('submit', (context, next) => {
Object.assign(context.op.m, context.agent.custom);
context.opMetadataProjection = { foo: true };
next();
});
As far as tests, I will try to figure out how to add them as soon as we finalize the direction we are heading in.
Is this inline with your thinking?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@alecgibson, just to clarify, it was not my intention to suggest that something is wrong with your approach. I am not that familiar with the code to even think in such terms. Please forgive my ignorance. My goal was to find the minimal backward compatible change possible in order to get the functionality working. As I spent more time diving through code it became clear that adding opMetadataProjection feature is that minimal backward compatible change. It was your initial suggestion that lead me in that direction and gave me better understanding of the inner workings. Thank you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No worries at all; was just checking that it does actually solve your problem or if I'd missed something.
I'm happy with this approach in general (since I suggested it 😛), but let's confirm next week; we have a weekly Pull Request meeting on Tuesdays, so we'll discuss there and get back to you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @alecgibson , did you have a weekly PR meeting on Tuesday?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@maxfortun we had to cancel the meeting this week because of have, but I've asked @ericyhwang to take a look when he gets a chance and we can discuss directly on the PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you.
lib/submit-request.js
Outdated
continue; | ||
} | ||
|
||
if (typeof doProject === 'function') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not entirely sure we need this bonus behaviour for a first implementation, unless you have some special use-case you need to tackle? Probably best to keep the API footprint as small as possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's keep the footprint small. Updated the commit.
lib/backend.js
Outdated
@@ -197,7 +197,7 @@ Backend.prototype.trigger = function(action, agent, request, callback) { | |||
if (err) return callback(err); | |||
var fn = fns.shift(); | |||
if (!fn) return callback(); | |||
fn(request, next); | |||
fn(request, callback); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we move this change to a separate PR — I haven't got the headspace to think about it right now (hopefully a bit more time next week), but I think:
- it's not conceptually part of this change
- it might need further discussion, and I don't want the two changes to hold one another up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am at conferences this and next week, so will not have time to refactor:( Will go through this right after.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @alecgibson , I am a bit puzzled as I am not sure how to create 2 separate PRs from a single fork. Any ideas?
In mean time I can revert this change if we can proceed with the rest of the PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should be able to just create a second branch on your fork, and raise a PR from there. You don't need to raise PRs from master
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@alecgibson , thank you. Moved into a separate PR [#517].
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall approach sounds fine to me.
Please add some tests for the projection - you can run the test suite locally with npm test
.
We recommend adding your tests inside test/client/submit.js, which tests the codepath you're modifying.
lib/submit-request.js
Outdated
} | ||
|
||
// Full projection | ||
return request.op.m; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you specifically need the ability to get all metadata?
If it's not something you need at the moment, I'd leave this out for now, returning undefined
as the fallback case.
Requesting just what's needed is better, so if we leave out a "get-everything" option, that removes the temptation to request all the metadata.
@ericyhwang I added a few tests to test/client/submit.js. Hopefully these are acceptable. |
lib/client/doc.js
Outdated
} | ||
this.emit('op batch', op.op, source); | ||
this.emit('op batch', op.op, source, {op: op}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this.emit('op batch', op.op, source, {op: op}); | |
this.emit('op batch', op.op, source, op.src, {op: op}); |
test/client/submit.js
Outdated
doc = connection.get('dogs', 'fido'); | ||
doc.create({name: 'fido'}, function() { | ||
doc.on('op', function(op, source, src, context) { | ||
expect(context.op.m).equal(undefined); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm assuming this is acceptable for your use case?
test/client/submit.js
Outdated
}); | ||
}); | ||
|
||
it('concurrent changes', function(done) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I understand what we're trying to test here?
We also seem to be missing a simple test that remote clients receive the projected metadata (the whole point of this PR!).
@alecgibson , I am trying to replicate the test failures on my local machine, and the tests keep passing. Any idea how do I run a test in exactly the same env as this repo does? |
|
@alecgibson that is precisely what I did several times: $ node --version
v14.18.1
$ npm install
<...snip...>
added 375 packages from 588 contributors in 12.972s
31 packages are looking for funding
run `npm fund` for details
$ npm test
> [email protected] test /home/fortunm/projects/sharedb
> mocha
<...snip...>
metadata projection
✓ passed metadata to connect
✓ passed metadata to submit
✓ received local op without metadata
✓ concurrent changes (204ms)
<...snip...>
615 passing (4s)
I tried reproducing test failures but they keep passing... Is there some sort of a timeout that I am exceeding because of the concurrent test? |
git clone https://github.com/share/sharedb.git
cd sharedb
gh pr checkout 513
npm i
npm test Decided to do a clean test. This one is on mac. Still passes. |
@maxfortun sorry about the confusion: the build is a little weird. It's not actually the The In order to replicate the failure, you'll have to clone cd sharedb-mongo
npm install path/to/sharedb
npm install
npm test I've tested locally and can confirm the failure. It's failing because you get
You can just use the op callback instead of the event: var connection = this.backend.connect(undefined, metadata);
var doc = null;
connection.on('connected', function() {
expect(connection.agent.custom).eql(metadata);
doc = connection.get('dogs', 'fido');
doc.create({name: 'fido'}, function() {
doc.submitOp([{p: ['tricks'], oi: ['fetch']}], {source: 'trainer'}, function(error) {
if (error) return done(error);
expect(context.op.m).equal(undefined);
done();
});
});
}); And in that case, you'll then see that the root cause is actually your assertion is failing:
|
@alecgibson , that sharedb-mongo explanation did the trick. Thank you. |
Adds feature [#512]
Use case: Visually track change attribution across remote clients.
[#224] allows to store
metadata
, which containsuserId
, but falls short of pushingmetadata
out to remote clients.Proposed solution:
On a server side:
Retain backend constructor options in order to pass around
options.opsOptions
to configure metadata behavior.Include
message.m
in agent's_sendOp
based onbackend.options.opsOptions.metadata
configuration.Include
op.m
in SubmitRequest based onbackend.options.opsOptions.metadata
configuration.On a client side:
Emit
op.m
with everyop
related event.