-
Notifications
You must be signed in to change notification settings - Fork 217
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
feat(xsnap): stream snapshots over process pipe #7541
Conversation
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 this is ok, I never understood the baton
bits well enough to read this properly, but I agree with the parts that I do understand: if snapshotUseFs
is false (which is the default), then we send snapshots by writing them to a specific fd, and we read snapshots on a separate (fixed) fd. The snapshot reads follow a sequence of:
- prepare a snapshot reader to collect bytes arriving on FD N
- write "please send a snapshot on FD N" on the command pipe
- wait until the command-response pipe returns an ack, with a size
- wait until the snapshot reader has seen that many bytes
- fire the promise that was returned earlier by
makeSnapshot
If it really does work that way, then this sounds good to me.
packages/xsnap/src/xsnap.js
Outdated
}); | ||
const postStart = await (snapshotStream && | ||
(snapshotUseFs | ||
? async () => { |
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.
that's a pretty big trinary expression.. is there an easy way to define a pair of functions earlier and then make the ?:
fit on a single line?
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 that and a little more
packages/xsnap/src/xsnap.js
Outdated
@@ -146,7 +175,17 @@ export async function xsnap(options) { | |||
console.log('XSNAP_DEBUG_RR', { bin, args }); | |||
} | |||
const xsnapProcess = spawn(bin, args, { | |||
stdio: ['ignore', stdout, stderr, 'pipe', 'pipe'], | |||
stdio: [ | |||
'ignore', |
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 add comments to label these, unless prettier
objects: fd numbers and their use
packages/xsnap/src/xsnap.js
Outdated
output.end(); | ||
}; | ||
const checkDone = () => { | ||
if (snapshotSize !== undefined && !(snapshotReadSize < snapshotSize)) { |
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.
.. && (snapshotReadSize >= snapshotSize)
would seem more readable, to me
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.
Yes but that is not equivalent in the case where snapshotSize
is null
. However that's just too complicated for it's own good, will simplify.
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.
yeah, let's not rely upon Number(null)===0
: if it's not a number, we shouldn't reach the comparison
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.
isNaN(Number(null)) === true
, but yes agreed
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.
Used -1
as that will always be lower than 0
:)
Technically it's wait until the consumer has seen that many bytes (or until it stops reading, whichever comes first), but since it's a simple pipe, that's the same thing.
This being I/O on separate pipes, there is no guarantee in the order of these 2, so they're reacted to in parallel.
There is no promise returned by |
Right, but you can't wait for
Oh, I thought
But aren't there (failure) cases where you don't get far enough to build the stream, and |
|
I'm not sure what "waiting for |
Yeah, I guess I should be more precise. The callback that is invoked (which is maybe really an async generator which gets re-activated each time the FD becomes readable and select/poll/libuv/etc notices) gets a set of bytes, and either has a target count (of course, we're depending upon the child process to be honest about If But, I trust that the sync return of an async generator means we'll always get a generator, and thus can rely upon the failure signal from the generator/stream, instead of needing a separate promise. And I'm willing to believe that one signalling mechanism is better than two. thanks! |
I do and I still made a mistake. Not a fatal one, but it was caught by one of the tests. Generator functions start suspended. That means the baton would only be taken by Who said that single threaded programs couldn't run into data races due to faulty synchronization? |
Apologies in advance, I will need to rebase this PR to amend the base before re-adding the commits. I will try to keep things consistent as much as possible and will not change the base to facilitate review. |
34c1ac3
to
6c276c5
Compare
@warner PTAL I've pushed some commits below the original ones, and interspersed the fixup commits addressing feedback to make sure this won't have any auto-squash rebase conflicts., but the original commits' diff should have barely changed besides simple conflict resolutions. |
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.
Ok, some suggested name changes and a pair of magic-number constants, but it seems pretty good to me. I'm not strong on the baton-passing stuff, so I'm still taking some of that on faith, and it'd be great if @kriskowal could take a second look.
packages/xsnap/test/test-xsnap.js
Outdated
const vat1 = await xsnap({ | ||
...options(io), | ||
handleCommand, | ||
snapshotStream: snapshotStream1, |
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.
nit: suggest snapshotStream: vat0.makeSnapshot(),
makes for a nice "clone the worker" idiom
packages/xsnap/src/xsnap.js
Outdated
* @param {string} [description] | ||
* @returns {AsyncGenerator<Uint8Array, void, undefined>} | ||
*/ | ||
function makeSnapshot(description = 'unknown') { |
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 still thinking this should be named makeSnapshotStream
, to make it clear that you aren't getting a Uint8Array
as a single big blob, and to make it clear that you can't just copy the result (since it's a single-use generator).
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.
And I think we should almost never pass plain Uint8Array
around, only using streams, making the Stream
suffix redundant ;)
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.
well, yeah, we have good reasons for using streams rather than multi-megabyte blobs, but a newcomer won't know that, and when we say "snapshot" in most other contexts (snapStore
tables, files on disk, ingest-snapshot.js
tools) we mean a blob of bytes, so their confusion would be justified
packages/xsnap/src/xsnap.js
Outdated
)}-XXXXXX.xss`, | ||
}); | ||
|
||
const cleanup = async () => fs.unlink(snapPath); |
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.
Will cleanup
be called temporally later than afterSpawn
? I'd define them in the same order, so swap these two lines.
packages/xsnap/src/xsnap.js
Outdated
}; | ||
|
||
return harden({ | ||
snapPath: `@8:${safeHintFromDescription(snapshotDescription)}`, |
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 works (I'm guessing the related xsnap change was to limit the atoi
to the separating colon), although I'm not sure we really need the snapshot ID on the command line (they're long and not very useful).
If we really need it to be available, we could stick it in an environment variable for the child process, where you could read it from /proc/PID/environ
.
But, this is fine for now, no need to change it today.
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.
so for atoi
while C
seem to say behavior is undefined when reaching a non numeric char, POSIX defines that the behavior is the one of strtol
, and stops at non-numeric chars. In a perfect world I'd go an be explicit about this on the xsnap-worker side, but I really don't want another PR just for that.
Also the description will only include the hash when loading the vat the first time. On snapshot reload it will only contain vatID-snapPos
. The reason I wanted to include is because for forced snapshot reload, I can end up with 2 process with the exact same args during a reload, not knowing which is which is which. I also have manually tested the old worker retirement, but being able to spot a problem with different cmdline is helpful if more than one process remains.
)}-XXXXXX.xss`, | ||
}); | ||
// eslint-disable-next-line @jessie.js/no-nested-await | ||
const handle = await fs.open(snapPath, 'w+'); |
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 w+
("writing and reading"). It sounds like this is the filename we send to the xsnap
"please write a snapshot" command, which means xsnap
will write to it, and we'll read from it. So should it use r
instead of w+
?
(using multiple modes on a single file is suspicious)
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 is a bit of stream opening race mitigation. Before I'd create the file name, tell xsnap write here (which it does with wb
), wait for the response, then create the stream, wait for the file open event, then hook the stream.
To simplify the logic, I'm abusing file modes, and now the flow is as follow: JS creates the file in w+
mode, aka create/truncate and allow reading, creates the read stream, tell xsnap to take the snapshot, wait for the response, and then connects the stream to the output.
It's a lot more similar to the wiring for the streaming snapshot, except for when the piping of source to output stream occurs. If not that, I'd have to create a thunk to call in the "maybePipe", with all the asynchronous error handling complications.
I can try to document this better.
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.
Ah, ok, so it's a bit like what tail -F
must do (wait for the file to exist, then wait for new data to appear). But here, the kernel side creates the file in the first place (so it can start waiting for new data right away), and the worker side opens a pre-existing file for writing (i.e. truncate, but the reader doesn't have to wait for them).
And of course Node's enthusiasm for async means that it's one notch more complicated: the kernel side can't just open the file, it has to start opening a file and then wait for the open to complete.
I'd be tempted to do a sync open(mode='w')
to create the empty file, then close it right away, then an async open(mode='r')
to set up the reader, with a comment in between to explain that we pre-create the file to avoid needing to watch for it to appear when the child starts writing. But I can imagine how that flow might be not as good as the mode='w+'
you're doing.
So yeah, please add a comment explaining what the two sides observe, and why w+
is a tolerable abuse.
packages/xsnap/src/xsnap.js
Outdated
finished(output).finally(() => sourceStream.destroy()); | ||
} else { | ||
sourceStream = savedSnapshotsStream; | ||
snapPath = '@7'; |
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 magic number should get a SNAPSHOT_SAVE_FD
constant too
output.on('data', onData); | ||
|
||
const result = batonKit.promise.then(async () => { | ||
await messagesToXsnap.next(encoder.encode(`w${snapPath}`)); |
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.
add comment: "tell xsnap to write a snapshot to disk or the pipe"
just to help readers orient themselves here
snapshotSize = Number(lengthStr); | ||
maybePipe(); | ||
} else { | ||
snapshotSize = -1; |
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.
What does it mean if we get here? I think it means that the snapshot write failed, and xsnap is trying to tell us there was an error, or maybe xsnap died in the middle of the process.
I guess snapshotSize = -1
means that cleanup()
will finish quickly, which is good, but where do we tell the stream (going into snapStore
?) that the data is bad, and it should not be saved? And how does the caller (who just hands the stream to snapStore
) learn that it failed? I guess maybe snapStore.saveSnapshot()
is supposed to throw when it sees the stream finish with an error?
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 finally
section checks the read size against this size, and if different throws, which means the consumer will see an error before its stream iteration ends. And yes, if iteration throws, the snapStore.saveSnapshot()
will not store anything and re-throw.
await fs.unlink(tmpSnapPath); | ||
await done; | ||
(piped && snapshotReadSize === snapshotSize) || | ||
Fail`Snapshot size does not match. saved=${q(snapshotSize)}, read=${q( |
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.
ah, ok, here's one place that would signal an error after snapshotSize = -1
, and since this at the top of async function* makeSnapshotInternal
, the generator given to snapStore will exit with an error. I think that works.
a49c075
to
c41472a
Compare
c41472a
to
3f77ff9
Compare
closes: #6363
refs: agoric-labs/xsnap-pub#39
Description
This introduces support for streaming the xsnap snapshot over an stdio pipe with the worker process, removing the need to transition through the filesystem.
Security Considerations
This ultimately might allow use to remove fs permissions from the xsnap-worker
Scaling Considerations
This should improve the performance of snapshot load and save, and enable future optimizations like teeing a snapshot we're making into a new worker will compressing it and saving it in parallel. See #6943
Documentation Considerations
Internal implementation detail triggered by new option (on by default.
Testing Considerations
Updated the xsnap snapshot test to exercise various combinations of save / load using fs or pipe, as well as making sure that multiple snapshot in a row over the pipe behave as expected.