-
Notifications
You must be signed in to change notification settings - Fork 940
Improve usage and testing of delayed operations. #499
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
Conversation
Core changes: * Moves ExponentialBackoff to the AsyncQueue (matches iOS / Android). * Adds a TimerId enum for identifying delayed operations on the queue and uses it to identify our existing backoff and idle timers. * Added AsyncQueue.hasDelayedOperation(id) and .runDelayedOperationsEarly(id) which can be used from tests to check for the presence of an operation and to schedule them to run early. * Idle tests now use these mechanisms. * Spec tests now use this rather than setting initalBackoffDelay to 1ms. * Reworked mechanism by which DelayedOperation objects get removed from AsyncQueue's delayedOperations list to make sure it happens synchronously. Cleanup: * Renamed schedule() to enqueue() and scheduleWithDelay() to enqueueAfterDelay(). * Reorders AsyncQueue.enqueueAfterDelay() arguments to put operation last.
* AsyncQueue. These IDs can then be used from tests to check for the presence | ||
* of operations or to run them early. | ||
*/ | ||
export enum TimerId { |
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 am a big fan of this Enum. Can we use well-defined stringslike we for the action strings in Persistence.runTransaction
(maybe make them of type TimeId
)? It seems strange that we would have to edit this type everytime we add a new task.
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 started out with a string union (type TimerId = 'listen_stream_idle'|...
), but it was feeling awkward to have long string constants that had to match between our tests and implementation code. And since iOS / Java don't have string unions I assume we'd want to use an enum there? So I just went with an enum here too.
I don't follow "It seems strange that we would have to edit this type everytime we add a new task." Wouldn't the same be true if we used a string union? Or were you thinking we'd forego type-safety? That's an option, but I do actually like having all of the timers defined in one place (so if you're adding or testing timers, you can reason about the interaction with the existing ones).
FWIW, I think the runTransaction strings are just used for logging, so they don't have the same constraint of being used from multiple places.
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 am sold my initial idea for this anymore either, but I was indeed thinking that we should forego type safety entirely. Something along those lines of
queue. enqueueAfterDelay('myamazingoperation', () => {}, ...)
queue. runDelayedOperationsEarly('myamazingoperation');
feels natural to me. The argument against this is that we will never actually use our API like this... ...and that's a pretty strong argument.
If we do have a single list of acceptable values, then it seems better to stick with the enum.
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.
Functionality wise, this PR looks fine to me. I wonder though if we need the guarantees it provides and if we can maybe reduce the complexity a bit by by relaxing some of them. This is a lot of code for a queue in JavaScript that essentially only wraps setTimeout
.
backoffAndWait(): Promise<void> { | ||
const def = new Deferred<void>(); | ||
backoffAndRun(op: () => Promise<void>): void { | ||
if (this.timerPromise !== null) { |
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.
With the old API, we had a way to tell the caller that its operation got cancelled. Right now, if I follow this correctly, we will just silently drop the previous invocation/
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.
Not 100% sure what you're referring to. Prior to my change we never canceled backoff timers. This actually means that you could end up with multiple outstanding backoff timers. While this doesn't /break/ anything, I'm not sure it was intentional and it means we might reconnect earlier than we should.
I did change the return type of this method to void instead of Promise, but that's just because we weren't using it and it matches iOS / Android now.
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 it as is for now, but I do think that it would be useful to be able to figure that when operations get cancelled. If we don't need this now, we can deal with across all platforms we end up needing it.
@@ -163,13 +163,15 @@ export abstract class PersistentStream< | |||
|
|||
constructor( | |||
private queue: AsyncQueue, | |||
backoffTimerId: TimerId, |
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 name makes it sound like you want to use different IDs for operations that get backed off. I don't think we should do that, and instead use the same ID regardless of how it gets scheduled.
Do you mind changing this to connectionTimerId
(or something similar) to indicate the operation type rather than the fact that it is can be run with backoff?
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.
Good point. Renamed. I also changed the TimerId enums (ListenStreamConnection instead of ListenStreamBackoff).
*/ | ||
backoffAndWait(): Promise<void> { | ||
const def = new Deferred<void>(); | ||
backoffAndRun(op: () => Promise<void>): void { |
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.
backoffAndRun
lets you schedule any type of operation here, but the ID already got assigned in the constructor. Should we move the assignment of the ID to here as well?
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.
Eh... The intention is that an ExponentialBackoff object is used for one purpose (restarting the listen stream for instance). It wouldn't make sense to do a different kind of operation each time backoff elapses. So I think it'd be more inline with the intent of ExponentialBackoff to move op
to the constructor, but I think that makes usage of the class more awkward. So I am inclined to keep it as-is.
private op: () => Promise<T> | ||
private readonly asyncQueue: AsyncQueue, | ||
readonly timerId: TimerId, | ||
readonly targetTime: number, |
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.
TargetTime is pretty unspecific. Should me make this a Date or call this targetTimeMs
?
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.
Oops, good call. Renamed to targetTimeMs. I don't want to use Date since I use this for sorting, and ms is much easier.
private asyncQueue: AsyncQueue, | ||
private op: () => Promise<T> | ||
private readonly asyncQueue: AsyncQueue, | ||
readonly timerId: TimerId, |
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.
Should this name actually be OperationId
or something similar?
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 OperationId is too generic (most operations don't have IDs). I actually named it DelayedOperationId at first, but I didn't like it. It was verbose, and I think "timer" conveys the purpose a lot better and makes all the usages clearer (idleTimerId vs idleOperationId, etc.). Conceptually, the client has various timers (for backoff, idle, etc.) and using these "timer ids", tests can now start to manipulate them...
If you feel strongly, I can rename it back to DelayedOperationId, but I'm still preferring TimerId.
this.delayedOperations.push(delayedOp); | ||
|
||
delayedOp.catch(err => {}).then(() => { |
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 way of dealing with the dequeuing was much more straightforward (albeit you need to re-throw the 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.
Yeah, the problem was it runs asynchronously and so the delayedOperations
list will sometimes contain canceled or already-run operations, causing various problems (like if you cancel a task and then immediately use asyncQueue.hasDelayedOperation()
to check for it, it'll erroneously return true).
It also won't port well to other platforms.
|
||
// Run ops in the same order they'd run if they ran naturally. | ||
this.delayedOperations.sort((a, b) => a.targetTime - b.targetTime); | ||
|
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 seems like a lot of hoops to go through that (at least for now) I don't cleary see the immediate benefit of. For unrelated operations scheduled with delay, it seems fine to a) run them out of order (removing the need for target time) and b) only run them when calling runDelayedOperationsEarly
.
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.
My goal is to basically give our tests control of time so you can "advance the clock" in a test and make sure the right things happen. When a test does this, I think operations should run in the same order they'll run in the real client. That way we can detect interactions between our various timeouts.
I'm not 100% sure what b) is referring to, but FWIW I don't think we want to always run all delayed operations. Right now for instance, the spec tests trigger a reconnect and drain the connection timer, but they don't want to drain the idle timer, and any other timers we add in the future.
@@ -25,7 +26,9 @@ apiDescribe('Idle Timeout', persistence => { | |||
return docRef | |||
.set({ foo: 'bar' }) | |||
.then(() => { | |||
return drainAsyncQueue(db); | |||
return asyncQueue(db).runDelayedOperationsEarly( | |||
TimerId.WriteStreamIdle |
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 would also read nicer to me if this was a constant on WriteStream.
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 will the type of this constant be? In Java?
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 was going to suggest a String but I think you already changed my mind of that.
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, sorry for the churn. All of this is pre-work for me adding (and testing) the new OnlineState timeout (#412).
I think this is worthwhile in order to have the ability for tests to manipulate time and verify that the right things happen.
*/ | ||
backoffAndWait(): Promise<void> { | ||
const def = new Deferred<void>(); | ||
backoffAndRun(op: () => Promise<void>): void { |
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.
Eh... The intention is that an ExponentialBackoff object is used for one purpose (restarting the listen stream for instance). It wouldn't make sense to do a different kind of operation each time backoff elapses. So I think it'd be more inline with the intent of ExponentialBackoff to move op
to the constructor, but I think that makes usage of the class more awkward. So I am inclined to keep it as-is.
backoffAndWait(): Promise<void> { | ||
const def = new Deferred<void>(); | ||
backoffAndRun(op: () => Promise<void>): void { | ||
if (this.timerPromise !== null) { |
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.
Not 100% sure what you're referring to. Prior to my change we never canceled backoff timers. This actually means that you could end up with multiple outstanding backoff timers. While this doesn't /break/ anything, I'm not sure it was intentional and it means we might reconnect earlier than we should.
I did change the return type of this method to void instead of Promise, but that's just because we weren't using it and it matches iOS / Android now.
@@ -163,13 +163,15 @@ export abstract class PersistentStream< | |||
|
|||
constructor( | |||
private queue: AsyncQueue, | |||
backoffTimerId: TimerId, |
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.
Good point. Renamed. I also changed the TimerId enums (ListenStreamConnection instead of ListenStreamBackoff).
private asyncQueue: AsyncQueue, | ||
private op: () => Promise<T> | ||
private readonly asyncQueue: AsyncQueue, | ||
readonly timerId: TimerId, |
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 OperationId is too generic (most operations don't have IDs). I actually named it DelayedOperationId at first, but I didn't like it. It was verbose, and I think "timer" conveys the purpose a lot better and makes all the usages clearer (idleTimerId vs idleOperationId, etc.). Conceptually, the client has various timers (for backoff, idle, etc.) and using these "timer ids", tests can now start to manipulate them...
If you feel strongly, I can rename it back to DelayedOperationId, but I'm still preferring TimerId.
private op: () => Promise<T> | ||
private readonly asyncQueue: AsyncQueue, | ||
readonly timerId: TimerId, | ||
readonly targetTime: number, |
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.
Oops, good call. Renamed to targetTimeMs. I don't want to use Date since I use this for sorting, and ms is much easier.
this.delayedOperations.push(delayedOp); | ||
|
||
delayedOp.catch(err => {}).then(() => { |
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, the problem was it runs asynchronously and so the delayedOperations
list will sometimes contain canceled or already-run operations, causing various problems (like if you cancel a task and then immediately use asyncQueue.hasDelayedOperation()
to check for it, it'll erroneously return true).
It also won't port well to other platforms.
|
||
// Run ops in the same order they'd run if they ran naturally. | ||
this.delayedOperations.sort((a, b) => a.targetTime - b.targetTime); | ||
|
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.
My goal is to basically give our tests control of time so you can "advance the clock" in a test and make sure the right things happen. When a test does this, I think operations should run in the same order they'll run in the real client. That way we can detect interactions between our various timeouts.
I'm not 100% sure what b) is referring to, but FWIW I don't think we want to always run all delayed operations. Right now for instance, the spec tests trigger a reconnect and drain the connection timer, but they don't want to drain the idle timer, and any other timers we add in the future.
@@ -25,7 +26,9 @@ apiDescribe('Idle Timeout', persistence => { | |||
return docRef | |||
.set({ foo: 'bar' }) | |||
.then(() => { | |||
return drainAsyncQueue(db); | |||
return asyncQueue(db).runDelayedOperationsEarly( | |||
TimerId.WriteStreamIdle |
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 will the type of this constant be? In Java?
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 looks fine to me (after some convincing), but I do still wonder if this could be simpler overall.
backoffAndWait(): Promise<void> { | ||
const def = new Deferred<void>(); | ||
backoffAndRun(op: () => Promise<void>): void { | ||
if (this.timerPromise !== null) { |
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 it as is for now, but I do think that it would be useful to be able to figure that when operations get cancelled. If we don't need this now, we can deal with across all platforms we end up needing it.
* AsyncQueue. These IDs can then be used from tests to check for the presence | ||
* of operations or to run them early. | ||
*/ | ||
export enum TimerId { |
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 am sold my initial idea for this anymore either, but I was indeed thinking that we should forego type safety entirely. Something along those lines of
queue. enqueueAfterDelay('myamazingoperation', () => {}, ...)
queue. runDelayedOperationsEarly('myamazingoperation');
feels natural to me. The argument against this is that we will never actually use our API like this... ...and that's a pretty strong argument.
If we do have a single list of acceptable values, then it seems better to stick with the enum.
ListenStreamIdle, | ||
ListenStreamConnection, | ||
WriteStreamIdle, | ||
WriteStreamConnection |
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 thinking more and more that we should combine these four states into two (StreamIdle, StreamConnection), which would simplify our stream constructors considerably.
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 that then we have to allow duplicates on the queue and it becomes ambiguous which one tests are manipulating, so I'm inclined to keep them separate. But I agree the stream constructor ends up awkward. :-/ I considered having them be abstract properties on the stream instead (so each subclass would need to define them appropriately), but I'm not sure that's actually better, and abstract doesn't port super great to Obj-C.
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 strikes me as pretty ugly too but I don't think reducing the number of states is worthwhile.
What if we fully constructed the exponential backoff and passed that in to the remote store instead of constructing it here?
Similarly can we encapsulate the idle id and duration into a "delayed runner"? That way the streams just have these dumb interfaces to delay tasks without having to distinguish how long to delay or which id to use where.
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 100% sure what you're proposing. We could do something like:
- Rename
TimerId
enum toTimeoutId
- Add a
Timeout
tuple class (encapsulating "idle id" and "duration" as you suggested):
class Timeout {
constructor(public delayMs: number, public timeoutId: TimoutId) { }
}
-
Change
asyncQueue.enqueueAfterDelay(timerId: TimerId, delayMs: number, op: () => Promise<T>)
to:asyncQueue.enqueueAfterTimeout(timeout: Timeout, op: () => Promise<T>)
-
Change PersistentStream constructor to accept an ExponentialBackoff object and Timeout object:
constructor(
private queue: AsyncQueue,
connectionTimeout: Timeout,
// or instead: connectionBackoff: ExponentialBackoff,
private idleTimeout: Timeout,
protected connection: Connection,
private credentialsProvider: CredentialsProvider
) { ... }
I don't have strong feelings about any of this, but it doesn't reduce the number of arguments to PersistentStream's constructor.
If you're proposing we actually have a DelayedRunner class/interface that encapsulates an AsyncQueue + Timeout so we can pass it into the stream and it can do idleTimeoutRunner.run(op)
, we could... but going the next step and trying to make ExponentialBackoff implement DelayedRunner as well would require surgery to move all the .reset() and .resetToMax() calls out of PersistentStream and I'm not sure it is feasible or makes sense.
And at the end of the day we're still passing in an "idleBLAH" and "connectionBLAH" to the PersistentStream constructor (where "BLAH" could be "TimerId" (today) or "Timeout" or "DelayedRunner").
So based on my understanding of your suggestion, I am not really excited... but let me know if my understanding missed.
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.
Nope, you understood what I meant. And yeah, it's not much of an improvement.
/** | ||
* For Tests: Determine if a particular delayed operation exists. | ||
*/ | ||
hasDelayedOperation(timerId: TimerId): boolean { |
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.
Don't feel strongly, but maybe use ContainsDelayedOperation here since has indicates a 1:1 mapping?
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.
Sure, sounds good.
@@ -25,7 +26,9 @@ apiDescribe('Idle Timeout', persistence => { | |||
return docRef | |||
.set({ foo: 'bar' }) | |||
.then(() => { | |||
return drainAsyncQueue(db); | |||
return asyncQueue(db).runDelayedOperationsEarly( | |||
TimerId.WriteStreamIdle |
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 was going to suggest a String but I think you already changed my mind of that.
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.
Thanks!
ListenStreamIdle, | ||
ListenStreamConnection, | ||
WriteStreamIdle, | ||
WriteStreamConnection |
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 that then we have to allow duplicates on the queue and it becomes ambiguous which one tests are manipulating, so I'm inclined to keep them separate. But I agree the stream constructor ends up awkward. :-/ I considered having them be abstract properties on the stream instead (so each subclass would need to define them appropriately), but I'm not sure that's actually better, and abstract doesn't port super great to Obj-C.
/** | ||
* For Tests: Determine if a particular delayed operation exists. | ||
*/ | ||
hasDelayedOperation(timerId: TimerId): boolean { |
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.
Sure, sounds good.
Core changes:
Cleanup:
This PR doesn't yet expose timer ids and async queue manipulation to the spec tests, but I suspect that's the direction we'll want to go.