mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
fix(tasks): don't continue if dependent task fails
Summary: Fixes T4291 If I made a final edit to a pre-existing draft and sent, we'd queue a `SyncbackDraftTask` before a `SendDraftTask`. This is important because since we have a valid draft `server_id`, the `SendDraftTask` will send by server_id, not by POSTing the whole body. If the `SyncbackDraftTask` fails, then we had a very serious issue whereby the `SendDraftTask` would keep on sending. Unfortunately the server never got the latest changes and sent the wrong version of the draft. This incorrect version would show up later when the `/send` endpoint returned the message that got actually sent. The solution was to make any queued `SendDraftTask` fail if a dependent `SyncbackDraftTask` failed. This meant we needed to make the requirements for `shouldWaitForTask` stricter, and block if tasks failed. Unfortunatley there was no infrastructure in place to do this. The first change was to change `shouldWaitForTask` to `isDependentTask`. If we're going to fail when a dependent task fails, I wanted the method name to reflect this. Now, if a dependent task fails, we recursively check the dependency tree (and check for cycles) and `dequeue` anything that needed that to succeed. I chose `dequeue` as the default action because it seemed as though all current uses of `shouldWaitForTask` really should bail if their dependencies fail. It's possible you don't want your task dequeued in this dependency case. You can return the special `Task.DO_NOT_DEQUEUE_ME` constant from the `onDependentTaskError` method. When a task gets dequeued because of the reason above, the `onDependentTaskError` callback gets fired. This gives tasks like the `SendDraftTask` a chance to notify the user that it bailed. Not all tasks need to notify. The next big issue was a better way to determine if a task truely errored to the point that we need to dequeue dependencies. In the Developer Status area we were showing tasks that had errored as "Green" because we caught the error and resolved with `Task.Status.Finished`. This used to be fine since nothing life-or-death cared if a task errored or not. Now that it might cause abortions down the line, we needed a more robust method then this. For one I changed `Task.Status.Finished` to a variety of finish types including `Task.Status.Success`. The way you "error" out is to `throw` or `Promise.reject` an `Error` object from the `performRemote` method. This allows us to propagate API errors up, and acts as a safety net that can catch any malformed code or unexpected responses. The developer bar now shows a much richer set of statuses instead of a binary one, which was REALLY helpful in debugging this. We also record when a Task got dequeued because of the conditions introduced here. Once all this was working we still had an issue of sending old drafts. If after a `SyncbackDraftTask` failed, now we'd block the send and notify the users as such. However, if we tried to send again, there was a separate issue whereby we wouldn't queue another `SyncbackDraftTask` to update the server with the latest information. Since our changes were persisted to the DB, we thought we had no changes, and therefore didn't need to queue a `SyncbackDraftTask`. The fix to this is to always force the creation of a `SyncbackDraftTask` before send regardless of the state of the `DraftStoreProxy`. Test Plan: new tests. Lots of manual testing Reviewers: bengotow Reviewed By: bengotow Subscribers: mg Maniphest Tasks: T4291 Differential Revision: https://phab.nylas.com/D2156
This commit is contained in:
parent
a926f84bc1
commit
531118ac5c
|
@ -35,12 +35,17 @@ class ComposerWithWindowProps extends React.Component
|
|||
_showInitialErrorDialog: (msg) ->
|
||||
remote = require('remote')
|
||||
dialog = remote.require('dialog')
|
||||
dialog.showMessageBox remote.getCurrentWindow(), {
|
||||
type: 'warning'
|
||||
buttons: ['Okay'],
|
||||
message: "Error"
|
||||
detail: msg
|
||||
}
|
||||
# We delay so the view has time to update the restored draft. If we
|
||||
# don't delay the modal may come up in a state where the draft looks
|
||||
# like it hasn't been restored or has been lost.
|
||||
_.delay ->
|
||||
dialog.showMessageBox remote.getCurrentWindow(), {
|
||||
type: 'warning'
|
||||
buttons: ['Okay'],
|
||||
message: "Error"
|
||||
detail: msg
|
||||
}
|
||||
, 100
|
||||
|
||||
module.exports =
|
||||
activate: (@state={}) ->
|
||||
|
|
|
@ -42,7 +42,14 @@ class DeveloperBarTask extends React.Component
|
|||
errCode = remoteError.statusCode ? ""
|
||||
errMessage = remoteError.body?.message ? remoteError?.message ? JSON.stringify(remoteError)
|
||||
|
||||
return "#{@props.task.constructor.name} #{errType} #{errCode} #{errMessage}"
|
||||
id = @props.task.id[-4..-1]
|
||||
|
||||
if qs.status
|
||||
status = "#{qs.status} (#{qs.debugStatus})"
|
||||
else
|
||||
status = "#{qs.debugStatus}"
|
||||
|
||||
return "#{@props.task.constructor.name} (ID: #{id}) #{status} #{errType} #{errCode} #{errMessage}"
|
||||
|
||||
_classNames: =>
|
||||
qs = @props.task.queueState ? {}
|
||||
|
|
|
@ -84,8 +84,14 @@ class DeveloperBar extends React.Component
|
|||
queue = @state.queue.filter(matchingFilter)
|
||||
queueDivs = for i in [@state.queue.length - 1..0] by -1
|
||||
task = @state.queue[i]
|
||||
# We need to pass the task separately because we want to update
|
||||
# when just that variable changes. Otherwise, since the `task`
|
||||
# pointer doesn't change, the `DeveloperBarTask` doesn't know to
|
||||
# update.
|
||||
status = @state.queue[i].queueState.status
|
||||
<DeveloperBarTask task={task}
|
||||
key={task.id}
|
||||
status={status}
|
||||
type="queued" />
|
||||
|
||||
queueCompleted = @state.completed.filter(matchingFilter)
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
|
||||
&.queue {
|
||||
padding: 0;
|
||||
.btn { float:right; }
|
||||
.btn { float:right; z-index: 10; }
|
||||
hr {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
|
|
@ -685,12 +685,15 @@ describe "DraftStore", ->
|
|||
beforeEach ->
|
||||
DraftStore._draftSessions = {}
|
||||
DraftStore._draftsSending = {}
|
||||
@forceCommit = false
|
||||
proxy =
|
||||
prepare: -> Promise.resolve(proxy)
|
||||
teardown: ->
|
||||
draft: -> {}
|
||||
changes:
|
||||
commit: -> Promise.resolve()
|
||||
commit: ({force}={}) =>
|
||||
@forceCommit = force
|
||||
Promise.resolve()
|
||||
DraftStore._draftSessions[draftClientId] = proxy
|
||||
spyOn(DraftStore, "_doneWithSession").andCallThrough()
|
||||
spyOn(DraftStore, "trigger")
|
||||
|
@ -744,6 +747,15 @@ describe "DraftStore", ->
|
|||
runs ->
|
||||
expect(atom.close).not.toHaveBeenCalled()
|
||||
|
||||
it "forces a commit to happen before sending", ->
|
||||
spyOn(Actions, "queueTask")
|
||||
runs ->
|
||||
DraftStore._onSendDraft(draftClientId)
|
||||
waitsFor ->
|
||||
DraftStore._doneWithSession.calls.length > 0
|
||||
runs ->
|
||||
expect(@forceCommit).toBe true
|
||||
|
||||
it "queues a SendDraftTask", ->
|
||||
spyOn(Actions, "queueTask")
|
||||
runs ->
|
||||
|
@ -785,7 +797,7 @@ describe "DraftStore", ->
|
|||
spyOn(dialog, "showMessageBox")
|
||||
DraftStore._draftsSending[draftClientId] = true
|
||||
Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId})
|
||||
advanceClock(10)
|
||||
advanceClock(200)
|
||||
expect(DraftStore.isSendingDraft(draftClientId)).toBe false
|
||||
expect(DraftStore.trigger).toHaveBeenCalledWith(draftClientId)
|
||||
expect(dialog.showMessageBox).toHaveBeenCalled()
|
||||
|
|
|
@ -127,6 +127,8 @@ describe "TaskQueue", ->
|
|||
TaskQueue._queue = [obsoleteTask, otherTask]
|
||||
TaskQueue._dequeueObsoleteTasks(replacementTask)
|
||||
expect(TaskQueue._queue.length).toBe(1)
|
||||
expect(obsoleteTask.queueState.status).toBe Task.Status.Continue
|
||||
expect(obsoleteTask.queueState.debugStatus).toBe Task.DebugStatus.DequeuedObsolete
|
||||
expect(TaskQueue.dequeue).toHaveBeenCalledWith(obsoleteTask)
|
||||
expect(TaskQueue.dequeue.calls.length).toBe(1)
|
||||
|
||||
|
@ -175,7 +177,7 @@ describe "TaskQueue", ->
|
|||
|
||||
it "doesn't process blocked tasks", ->
|
||||
class BlockedByTaskA extends Task
|
||||
shouldWaitForTask: (other) -> other instanceof TaskSubclassA
|
||||
isDependentTask: (other) -> other instanceof TaskSubclassA
|
||||
|
||||
taskA = new TaskSubclassA()
|
||||
otherTask = new Task()
|
||||
|
@ -197,9 +199,9 @@ describe "TaskQueue", ->
|
|||
expect(taskA.runRemote).toHaveBeenCalled()
|
||||
expect(blockedByTaskA.runRemote).not.toHaveBeenCalled()
|
||||
|
||||
it "doesn't block itself, even if the shouldWaitForTask method is implemented naively", ->
|
||||
it "doesn't block itself, even if the isDependentTask method is implemented naively", ->
|
||||
class BlockingTask extends Task
|
||||
shouldWaitForTask: (other) -> other instanceof BlockingTask
|
||||
isDependentTask: (other) -> other instanceof BlockingTask
|
||||
|
||||
blockedTask = new BlockingTask()
|
||||
spyOn(blockedTask, "runRemote").andCallFake -> Promise.resolve()
|
||||
|
@ -215,3 +217,80 @@ describe "TaskQueue", ->
|
|||
TaskQueue._queue = [task]
|
||||
TaskQueue._processTask(task)
|
||||
expect(task.queueState.isProcessing).toBe true
|
||||
|
||||
describe "handling task runRemote task errors", ->
|
||||
spyAACallback = jasmine.createSpy("onDependentTaskError")
|
||||
spyBBRemote = jasmine.createSpy("performRemote")
|
||||
spyBBCallback = jasmine.createSpy("onDependentTaskError")
|
||||
spyCCRemote = jasmine.createSpy("performRemote")
|
||||
spyCCCallback = jasmine.createSpy("onDependentTaskError")
|
||||
|
||||
beforeEach ->
|
||||
testError = new Error("Test Error")
|
||||
@testError = testError
|
||||
class TaskAA extends Task
|
||||
onDependentTaskError: spyAACallback
|
||||
performRemote: ->
|
||||
# We reject instead of `throw` because jasmine thinks this
|
||||
# `throw` is in the context of the test instead of the context
|
||||
# of the calling promise in task-queue.coffee
|
||||
return Promise.reject(testError)
|
||||
|
||||
class TaskBB extends Task
|
||||
isDependentTask: (other) -> other instanceof TaskAA
|
||||
onDependentTaskError: spyBBCallback
|
||||
performRemote: spyBBRemote
|
||||
|
||||
class TaskCC extends Task
|
||||
isDependentTask: (other) -> other instanceof TaskBB
|
||||
onDependentTaskError: (task, err) ->
|
||||
spyCCCallback(task, err)
|
||||
return Task.DO_NOT_DEQUEUE_ME
|
||||
performRemote: spyCCRemote
|
||||
|
||||
@taskAA = new TaskAA
|
||||
@taskAA.queueState.localComplete = true
|
||||
@taskBB = new TaskBB
|
||||
@taskBB.queueState.localComplete = true
|
||||
@taskCC = new TaskCC
|
||||
@taskCC.queueState.localComplete = true
|
||||
|
||||
spyOn(TaskQueue, 'trigger')
|
||||
|
||||
# Don't keep processing the queue
|
||||
spyOn(TaskQueue, '_updateSoon')
|
||||
|
||||
it "catches the error and dequeues the task", ->
|
||||
spyOn(TaskQueue, 'dequeue')
|
||||
waitsForPromise =>
|
||||
TaskQueue._processTask(@taskAA).then =>
|
||||
expect(TaskQueue.dequeue).toHaveBeenCalledWith(@taskAA)
|
||||
expect(spyAACallback).not.toHaveBeenCalled()
|
||||
expect(@taskAA.queueState.remoteError.message).toBe "Test Error"
|
||||
|
||||
it "calls `onDependentTaskError` on dependent tasks", ->
|
||||
spyOn(TaskQueue, 'dequeue').andCallThrough()
|
||||
TaskQueue._queue = [@taskAA, @taskBB, @taskCC]
|
||||
waitsForPromise =>
|
||||
TaskQueue._processTask(@taskAA).then =>
|
||||
expect(TaskQueue.dequeue.calls.length).toBe 2
|
||||
# NOTE: The recursion goes depth-first. The leafs are called
|
||||
# first
|
||||
expect(TaskQueue.dequeue.calls[0].args[0]).toBe @taskBB
|
||||
expect(TaskQueue.dequeue.calls[1].args[0]).toBe @taskAA
|
||||
expect(spyAACallback).not.toHaveBeenCalled()
|
||||
expect(spyBBCallback).toHaveBeenCalledWith(@taskAA, @testError)
|
||||
expect(@taskAA.queueState.remoteError.message).toBe "Test Error"
|
||||
expect(@taskBB.queueState.status).toBe Task.Status.Continue
|
||||
expect(@taskBB.queueState.debugStatus).toBe Task.DebugStatus.DequeuedDependency
|
||||
|
||||
it "dequeues all dependent tasks except those that return `Task.DO_NOT_DEQUEUE_ME` from their callbacks", ->
|
||||
spyOn(TaskQueue, 'dequeue').andCallThrough()
|
||||
TaskQueue._queue = [@taskAA, @taskBB, @taskCC]
|
||||
waitsForPromise =>
|
||||
TaskQueue._processTask(@taskAA).then =>
|
||||
expect(TaskQueue._queue).toEqual [@taskCC]
|
||||
expect(spyCCCallback).toHaveBeenCalledWith(@taskBB, @testError)
|
||||
expect(@taskCC.queueState.status).toBe null
|
||||
expect(@taskCC.queueState.debugStatus).toBe Task.DebugStatus.JustConstructed
|
||||
|
||||
|
|
|
@ -205,15 +205,15 @@ describe "ChangeMailTask", ->
|
|||
@task.messages = []
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (code) =>
|
||||
expect(code).toEqual(Task.Status.Finished)
|
||||
expect(code).toEqual(Task.Status.Success)
|
||||
|
||||
describe "if performRequests resolves", ->
|
||||
it "should resolve with Task.Status.Finished", ->
|
||||
it "should resolve with Task.Status.Success", ->
|
||||
@task = new ChangeMailTask()
|
||||
spyOn(@task, 'performRequests').andReturn(Promise.resolve())
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (result) =>
|
||||
expect(result).toBe(Task.Status.Finished)
|
||||
expect(result).toBe(Task.Status.Success)
|
||||
|
||||
describe "if performRequests rejects with a permanent network error", ->
|
||||
beforeEach ->
|
||||
|
@ -227,10 +227,10 @@ describe "ChangeMailTask", ->
|
|||
expect(@task.performLocal).toHaveBeenCalled()
|
||||
expect(@task._isReverting).toBe(true)
|
||||
|
||||
it "should resolve with finished after reverting", ->
|
||||
it "should resolve with Task.Status.Failed after reverting", ->
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (result) =>
|
||||
expect(result).toBe(Task.Status.Finished)
|
||||
expect(result).toBe(Task.Status.Failed)
|
||||
|
||||
describe "if performRequests rejects with a temporary network error", ->
|
||||
beforeEach ->
|
||||
|
@ -450,7 +450,7 @@ describe "ChangeMailTask", ->
|
|||
@task.performLocal().then =>
|
||||
expect(@task._lockAll).toHaveBeenCalled()
|
||||
|
||||
describe "when performRemote is returning Task.Status.Finished", ->
|
||||
describe "when performRemote is returning Task.Status.Success", ->
|
||||
it "should clean up locks", ->
|
||||
spyOn(@task, 'performRequests').andReturn(Promise.resolve())
|
||||
spyOn(@task, '_ensureLocksRemoved')
|
||||
|
@ -458,7 +458,7 @@ describe "ChangeMailTask", ->
|
|||
@task.performRemote().then =>
|
||||
expect(@task._ensureLocksRemoved).toHaveBeenCalled()
|
||||
|
||||
describe "when performRemote is returning Task.Status.Finished after reverting", ->
|
||||
describe "when performRemote is returning Task.Status.Failed after reverting", ->
|
||||
it "should clean up locks", ->
|
||||
spyOn(@task, 'performRequests').andReturn(Promise.reject(new APIError(statusCode: 400)))
|
||||
spyOn(@task, '_ensureLocksRemoved')
|
||||
|
@ -544,7 +544,7 @@ describe "ChangeMailTask", ->
|
|||
task._restoreValues = null
|
||||
expect( -> task.createUndoTask()).toThrow()
|
||||
|
||||
describe "shouldWaitForTask", ->
|
||||
describe "isDependentTask", ->
|
||||
it "should return true if another, older ChangeMailTask involves the same threads", ->
|
||||
a = new ChangeMailTask()
|
||||
a.threads = ['t1', 't2', 't3']
|
||||
|
@ -555,8 +555,8 @@ describe "ChangeMailTask", ->
|
|||
c = new ChangeMailTask()
|
||||
c.threads = ['t0', 't7']
|
||||
c.creationDate = new Date(3000)
|
||||
expect(a.shouldWaitForTask(b)).toEqual(false)
|
||||
expect(a.shouldWaitForTask(c)).toEqual(false)
|
||||
expect(b.shouldWaitForTask(a)).toEqual(true)
|
||||
expect(c.shouldWaitForTask(a)).toEqual(false)
|
||||
expect(c.shouldWaitForTask(b)).toEqual(true)
|
||||
expect(a.isDependentTask(b)).toEqual(false)
|
||||
expect(a.isDependentTask(c)).toEqual(false)
|
||||
expect(b.isDependentTask(a)).toEqual(true)
|
||||
expect(c.isDependentTask(a)).toEqual(false)
|
||||
expect(c.isDependentTask(b)).toEqual(true)
|
||||
|
|
|
@ -115,9 +115,7 @@ describe "FileUploadTask", ->
|
|||
@taskExitStatus = null
|
||||
@runWithError = (simulatedError) =>
|
||||
runs ->
|
||||
@task.performRemote().catch (err) ->
|
||||
console.log(err)
|
||||
.then (status) =>
|
||||
@task.performRemote().then (status) =>
|
||||
@taskExitStatus = status
|
||||
|
||||
waitsFor ->
|
||||
|
@ -132,7 +130,8 @@ describe "FileUploadTask", ->
|
|||
|
||||
describe "if the error is permanent", ->
|
||||
beforeEach ->
|
||||
@runWithError(new APIError(statusCode: 400))
|
||||
@apiError = new APIError(statusCode: 400)
|
||||
@runWithError(@apiError)
|
||||
|
||||
it "should broadcast `failed` if the error is permanent", ->
|
||||
runs ->
|
||||
|
@ -140,9 +139,9 @@ describe "FileUploadTask", ->
|
|||
dataReceived = Actions.uploadStateChanged.calls[0].args[0]
|
||||
expect(_.isMatch(dataReceived, data)).toBe(true)
|
||||
|
||||
it "should resolve with `finished`", ->
|
||||
runs ->
|
||||
expect(@taskExitStatus).toBe(Task.Status.Finished)
|
||||
it "should report Failed with the APIError", ->
|
||||
runs =>
|
||||
expect(@taskExitStatus).toEqual([Task.Status.Failed, @apiError])
|
||||
|
||||
describe "if the error is temporary", ->
|
||||
beforeEach ->
|
||||
|
@ -162,6 +161,10 @@ describe "FileUploadTask", ->
|
|||
dataReceived = Actions.uploadStateChanged.calls[0].args[0]
|
||||
expect(_.isMatch(dataReceived, data)).toBe(true)
|
||||
|
||||
it "should resolve with Task.Status.Failed", ->
|
||||
runs ->
|
||||
expect(@taskExitStatus).toBe(Task.Status.Failed)
|
||||
|
||||
describe "when the remote API request succeeds", ->
|
||||
beforeEach ->
|
||||
@simulateRequestSuccessImmediately = true
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
NylasAPI = require '../../src/flux/nylas-api'
|
||||
Actions = require '../../src/flux/actions'
|
||||
SyncbackDraftTask = require '../../src/flux/tasks/syncback-draft'
|
||||
FileUploadTask = require '../../src/flux/tasks/file-upload-task'
|
||||
SendDraftTask = require '../../src/flux/tasks/send-draft'
|
||||
DatabaseStore = require '../../src/flux/stores/database-store'
|
||||
{APIError} = require '../../src/flux/errors'
|
||||
|
@ -10,7 +11,7 @@ SoundRegistry = require '../../src/sound-registry'
|
|||
_ = require 'underscore'
|
||||
|
||||
describe "SendDraftTask", ->
|
||||
describe "shouldWaitForTask", ->
|
||||
describe "isDependentTask", ->
|
||||
it "should return true if there are SyncbackDraftTasks for the same draft", ->
|
||||
@draftA = new Message
|
||||
version: '1'
|
||||
|
@ -36,7 +37,7 @@ describe "SendDraftTask", ->
|
|||
@saveB = new SyncbackDraftTask('localid-B')
|
||||
@sendA = new SendDraftTask('localid-A')
|
||||
|
||||
expect(@sendA.shouldWaitForTask(@saveA)).toBe(true)
|
||||
expect(@sendA.isDependentTask(@saveA)).toBe(true)
|
||||
|
||||
describe "performLocal", ->
|
||||
it "should throw an exception if the first parameter is not a clientId", ->
|
||||
|
@ -275,3 +276,21 @@ describe "SendDraftTask", ->
|
|||
waitsForPromise =>
|
||||
@task.performRemote().catch (error) ->
|
||||
expect(error).toBe "DB error"
|
||||
|
||||
describe "failing dependent task", ->
|
||||
it "notifies the user that the required draft save failed", ->
|
||||
task = new SendDraftTask("local-1234")
|
||||
syncback = new SyncbackDraftTask('local-1234')
|
||||
spyOn(task, "_notifyUserOfError")
|
||||
task.onDependentTaskError(syncback, new Error("Oh no"))
|
||||
expect(task._notifyUserOfError).toHaveBeenCalled()
|
||||
expect(task._notifyUserOfError.calls.length).toBe 1
|
||||
|
||||
it "notifies the user that the required file upload failed", ->
|
||||
task = new SendDraftTask("local-1234")
|
||||
fileUploadTask = new FileUploadTask('/dev/null', 'local-1234')
|
||||
spyOn(task, "_notifyUserOfError")
|
||||
task.onDependentTaskError(fileUploadTask, new Error("Oh no"))
|
||||
expect(task._notifyUserOfError).toHaveBeenCalled()
|
||||
expect(task._notifyUserOfError.calls.length).toBe 1
|
||||
|
||||
|
|
|
@ -48,6 +48,84 @@ describe "SyncbackDraftTask", ->
|
|||
fn()
|
||||
return Promise.resolve()
|
||||
|
||||
describe "queueing multiple tasks", ->
|
||||
beforeEach ->
|
||||
@taskA = new SyncbackDraftTask("draft-123")
|
||||
@taskB = new SyncbackDraftTask("draft-123")
|
||||
@taskC = new SyncbackDraftTask("draft-123")
|
||||
@taskOther = new SyncbackDraftTask("draft-456")
|
||||
|
||||
now = Date.now()
|
||||
@taskA.creationDate = now - 20
|
||||
@taskB.creationDate = now - 10
|
||||
@taskC.creationDate = now
|
||||
TaskQueue._queue = []
|
||||
|
||||
it "dequeues other SyncbackDraftTasks that haven't started yet", ->
|
||||
# Task A is taking forever, B is waiting on it, and C gets queued.
|
||||
[@taskA, @taskB, @taskOther].forEach (t) ->
|
||||
t.queueState.localComplete = true
|
||||
|
||||
# taskA has already started This should NOT get dequeued
|
||||
@taskA.queueState.isProcessing = true
|
||||
|
||||
# taskB hasn't started yet! This should get dequeued
|
||||
@taskB.queueState.isProcessing = false
|
||||
|
||||
# taskOther, while unstarted, doesn't match the draftId and should
|
||||
# not get dequeued
|
||||
@taskOther.queueState.isProcessing = false
|
||||
|
||||
TaskQueue._queue = [@taskA, @taskB, @taskOther]
|
||||
spyOn(@taskC, "runLocal").andReturn Promise.resolve()
|
||||
|
||||
TaskQueue.enqueue(@taskC)
|
||||
|
||||
# Note that taskB is gone, taskOther was untouched, and taskC was
|
||||
# added.
|
||||
expect(TaskQueue._queue).toEqual = [@taskA, @taskOther, @taskC]
|
||||
|
||||
expect(@taskC.runLocal).toHaveBeenCalled()
|
||||
|
||||
it "waits for any other inflight tasks to finish or error", ->
|
||||
@taskA.queueState.localComplete = true
|
||||
@taskA.queueState.isProcessing = true
|
||||
@taskB.queueState.localComplete = true
|
||||
spyOn(@taskB, "runRemote").andReturn Promise.resolve()
|
||||
|
||||
TaskQueue._queue = [@taskA, @taskB]
|
||||
|
||||
# Since taskA has isProcessing set to true, it will just be passed
|
||||
# over. We expect taskB to fail the `_taskIsBlocked` test
|
||||
TaskQueue._processQueue()
|
||||
advanceClock(100)
|
||||
expect(TaskQueue._queue).toEqual [@taskA, @taskB]
|
||||
expect(@taskA.queueState.isProcessing).toBe true
|
||||
expect(@taskB.queueState.isProcessing).toBe false
|
||||
expect(@taskB.runRemote).not.toHaveBeenCalled()
|
||||
|
||||
it "does not get dequeued if dependent tasks fail", ->
|
||||
@taskA.queueState.localComplete = true
|
||||
@taskB.queueState.localComplete = true
|
||||
|
||||
spyOn(@taskA, "performRemote").andReturn Promise.resolve(Task.Status.Failed)
|
||||
spyOn(@taskB, "performRemote").andReturn Promise.resolve(Task.Status.Success)
|
||||
|
||||
spyOn(TaskQueue, "dequeue").andCallThrough()
|
||||
spyOn(TaskQueue, "trigger")
|
||||
|
||||
TaskQueue._queue = [@taskA, @taskB]
|
||||
TaskQueue._processQueue()
|
||||
advanceClock(100)
|
||||
TaskQueue._processQueue()
|
||||
advanceClock(100)
|
||||
expect(@taskA.performRemote).toHaveBeenCalled()
|
||||
expect(@taskB.performRemote).toHaveBeenCalled()
|
||||
expect(TaskQueue.dequeue.calls.length).toBe 2
|
||||
|
||||
expect(@taskA.queueState.debugStatus).not.toBe Task.DebugStatus.DequeuedDependency
|
||||
expect(@taskA.queueState.debugStatus).not.toBe Task.DebugStatus.DequeuedDependency
|
||||
|
||||
describe "performRemote", ->
|
||||
beforeEach ->
|
||||
spyOn(NylasAPI, 'makeRequest').andCallFake (opts) ->
|
||||
|
@ -124,14 +202,15 @@ describe "SyncbackDraftTask", ->
|
|||
expect(status).toBe Task.Status.Retry
|
||||
|
||||
[500, 0].forEach (code) ->
|
||||
it "Aborts on #{code} errors when we're PUT-ing", ->
|
||||
it "Fails on #{code} errors when we're PUT-ing", ->
|
||||
stubAPI(code, "PUT")
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (status) =>
|
||||
@task.performRemote().then ([status, err]) =>
|
||||
expect(status).toBe Task.Status.Failed
|
||||
expect(@task.getLatestLocalDraft).toHaveBeenCalled()
|
||||
expect(@task.getLatestLocalDraft.calls.length).toBe 1
|
||||
expect(@task.detatchFromRemoteID).not.toHaveBeenCalled()
|
||||
expect(status).toBe Task.Status.Finished
|
||||
expect(err.statusCode).toBe code
|
||||
|
||||
describe 'when POST-ing', ->
|
||||
beforeEach ->
|
||||
|
@ -140,20 +219,21 @@ describe "SyncbackDraftTask", ->
|
|||
spyOn(@task, "detatchFromRemoteID").andCallFake -> Promise.resolve(localDraft())
|
||||
|
||||
[400, 404, 409, 500, 0].forEach (code) ->
|
||||
it "Aborts on #{code} errors when we're POST-ing", ->
|
||||
it "Fails on #{code} errors when we're POST-ing", ->
|
||||
stubAPI(code, "POST")
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (status) =>
|
||||
@task.performRemote().then ([status, err]) =>
|
||||
expect(status).toBe Task.Status.Failed
|
||||
expect(@task.getLatestLocalDraft).toHaveBeenCalled()
|
||||
expect(@task.getLatestLocalDraft.calls.length).toBe 1
|
||||
expect(@task.detatchFromRemoteID).not.toHaveBeenCalled()
|
||||
expect(status).toBe Task.Status.Finished
|
||||
expect(err.statusCode).toBe code
|
||||
|
||||
it "Aborts on unknown errors", ->
|
||||
it "Fails on unknown errors", ->
|
||||
spyOn(NylasAPI, "makeRequest").andCallFake -> Promise.reject(new APIError())
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (status) =>
|
||||
@task.performRemote().then ([status, err]) =>
|
||||
expect(status).toBe Task.Status.Failed
|
||||
expect(@task.getLatestLocalDraft).toHaveBeenCalled()
|
||||
expect(@task.getLatestLocalDraft.calls.length).toBe 1
|
||||
expect(@task.detatchFromRemoteID).not.toHaveBeenCalled()
|
||||
expect(status).toBe Task.Status.Finished
|
||||
|
|
|
@ -12,13 +12,13 @@ describe "Task", ->
|
|||
describe "initial state", ->
|
||||
it "should set up queue state with additional information about local/remote", ->
|
||||
task = new Task()
|
||||
expect(task.queueState).toEqual({ isProcessing : false, localError : null, localComplete : false, remoteError : null, remoteAttempts : 0, remoteComplete : false })
|
||||
expect(task.queueState).toEqual({ isProcessing : false, localError : null, localComplete : false, remoteError : null, remoteAttempts : 0, remoteComplete : false, status: null, debugStatus: Task.DebugStatus.JustConstructed})
|
||||
|
||||
describe "runLocal", ->
|
||||
beforeEach ->
|
||||
class APITestTask extends Task
|
||||
performLocal: -> Promise.resolve()
|
||||
performRemote: -> Promise.resolve(Task.Status.Finished)
|
||||
performRemote: -> Promise.resolve(Task.Status.Success)
|
||||
@task = new APITestTask()
|
||||
|
||||
describe "when performLocal is not complete", ->
|
||||
|
@ -73,10 +73,37 @@ describe "Task", ->
|
|||
advanceClock()
|
||||
expect(@task.performRemote).toHaveBeenCalled()
|
||||
|
||||
it "it should resolve Continue if it already ran", ->
|
||||
@task.queueState.remoteComplete = true
|
||||
waitsForPromise =>
|
||||
@task.runRemote().then (status) =>
|
||||
expect(@task.queueState.status).toBe Task.Status.Continue
|
||||
expect(status).toBe Task.Status.Continue
|
||||
|
||||
it "marks as complete if the task 'continue's", ->
|
||||
spyOn(@task, 'performRemote').andCallFake ->
|
||||
Promise.resolve(Task.Status.Continue)
|
||||
@task.runRemote()
|
||||
advanceClock()
|
||||
expect(@task.performRemote).toHaveBeenCalled()
|
||||
expect(@task.queueState.remoteError).toBe(null)
|
||||
expect(@task.queueState.remoteComplete).toBe(true)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Continue)
|
||||
|
||||
it "marks as failed if the task reverts", ->
|
||||
spyOn(@task, 'performRemote').andCallFake ->
|
||||
Promise.resolve(Task.Status.Failed)
|
||||
@task.runRemote()
|
||||
advanceClock()
|
||||
expect(@task.performRemote).toHaveBeenCalled()
|
||||
expect(@task.queueState.remoteError).toBe(null)
|
||||
expect(@task.queueState.remoteComplete).toBe(true)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Failed)
|
||||
|
||||
describe "when performRemote resolves", ->
|
||||
beforeEach ->
|
||||
spyOn(@task, 'performRemote').andCallFake ->
|
||||
Promise.resolve(Task.Status.Finished)
|
||||
Promise.resolve(Task.Status.Success)
|
||||
|
||||
it "should save that performRemote is complete with no errors", ->
|
||||
@task.runRemote()
|
||||
|
@ -84,6 +111,7 @@ describe "Task", ->
|
|||
expect(@task.performRemote).toHaveBeenCalled()
|
||||
expect(@task.queueState.remoteError).toBe(null)
|
||||
expect(@task.queueState.remoteComplete).toBe(true)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Success)
|
||||
|
||||
it "should only allow the performRemote method to return a Task.Status", ->
|
||||
result = null
|
||||
|
@ -96,6 +124,7 @@ describe "Task", ->
|
|||
@ok.queueState.localComplete = true
|
||||
@ok.runRemote().then (r) -> result = r
|
||||
advanceClock()
|
||||
expect(@ok.queueState.status).toBe(Task.Status.Retry)
|
||||
expect(result).toBe(Task.Status.Retry)
|
||||
|
||||
class BadTask extends Task
|
||||
|
@ -106,15 +135,10 @@ describe "Task", ->
|
|||
advanceClock()
|
||||
expect(err.message).toBe('performRemote returned lalal, which is not a Task.Status')
|
||||
|
||||
describe "when performRemote rejects", ->
|
||||
describe "when performRemote rejects multiple times", ->
|
||||
beforeEach ->
|
||||
@error = new APIError("Oh no!")
|
||||
spyOn(@task, 'performRemote').andCallFake => Promise.reject(@error)
|
||||
|
||||
it "should save the error to the queueState", ->
|
||||
@task.runRemote().catch(noop)
|
||||
advanceClock()
|
||||
expect(@task.queueState.remoteError).toBe(@error)
|
||||
spyOn(@task, 'performRemote').andCallFake =>
|
||||
Promise.resolve(Task.Status.Failed)
|
||||
|
||||
it "should increment the number of attempts", ->
|
||||
runs ->
|
||||
|
@ -125,3 +149,72 @@ describe "Task", ->
|
|||
@task.runRemote().catch(noop)
|
||||
waitsFor ->
|
||||
@task.queueState.remoteAttempts == 2
|
||||
|
||||
describe "when performRemote resolves with Task.Status.Failed", ->
|
||||
beforeEach ->
|
||||
spyOn(atom, "emitError")
|
||||
@error = new APIError("Oh no!")
|
||||
spyOn(@task, 'performRemote').andCallFake =>
|
||||
Promise.resolve(Task.Status.Failed)
|
||||
|
||||
it "Should handle the error as a caught Failure", ->
|
||||
waitsForPromise =>
|
||||
@task.runRemote().then ->
|
||||
throw new Error("Should not resolve")
|
||||
.catch (err) =>
|
||||
expect(@task.queueState.remoteError instanceof Error).toBe true
|
||||
expect(@task.queueState.remoteAttempts).toBe(1)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Failed)
|
||||
expect(atom.emitError).not.toHaveBeenCalled()
|
||||
|
||||
describe "when performRemote resolves with Task.Status.Failed and an error", ->
|
||||
beforeEach ->
|
||||
spyOn(atom, "emitError")
|
||||
@error = new APIError("Oh no!")
|
||||
spyOn(@task, 'performRemote').andCallFake =>
|
||||
Promise.resolve([Task.Status.Failed, @error])
|
||||
|
||||
it "Should handle the error as a caught Failure", ->
|
||||
waitsForPromise =>
|
||||
@task.runRemote().then ->
|
||||
throw new Error("Should not resolve")
|
||||
.catch (err) =>
|
||||
expect(@task.queueState.remoteError).toBe(@error)
|
||||
expect(@task.queueState.remoteAttempts).toBe(1)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Failed)
|
||||
expect(atom.emitError).not.toHaveBeenCalled()
|
||||
|
||||
describe "when performRemote rejects with Task.Status.Failed", ->
|
||||
beforeEach ->
|
||||
spyOn(atom, "emitError")
|
||||
@error = new APIError("Oh no!")
|
||||
spyOn(@task, 'performRemote').andCallFake =>
|
||||
Promise.reject([Task.Status.Failed, @error])
|
||||
|
||||
it "Should handle the rejection as normal", ->
|
||||
waitsForPromise =>
|
||||
@task.runRemote().then ->
|
||||
throw new Error("Should not resolve")
|
||||
.catch (err) =>
|
||||
expect(@task.queueState.remoteError).toBe(@error)
|
||||
expect(@task.queueState.remoteAttempts).toBe(1)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Failed)
|
||||
expect(atom.emitError).not.toHaveBeenCalled()
|
||||
|
||||
describe "when performRemote throws an unknown error", ->
|
||||
beforeEach ->
|
||||
spyOn(atom, "emitError")
|
||||
@error = new Error("Oh no!")
|
||||
spyOn(@task, 'performRemote').andCallFake =>
|
||||
throw @error
|
||||
|
||||
it "Should handle the error as an uncaught error", ->
|
||||
waitsForPromise =>
|
||||
@task.runRemote().then ->
|
||||
throw new Error("Should not resolve")
|
||||
.catch (err) =>
|
||||
expect(@task.queueState.remoteError).toBe(@error)
|
||||
expect(@task.queueState.remoteAttempts).toBe(1)
|
||||
expect(@task.queueState.status).toBe(Task.Status.Failed)
|
||||
expect(@task.queueState.debugStatus).toBe(Task.DebugStatus.UncaughtError)
|
||||
expect(atom.emitError).toHaveBeenCalledWith(@error)
|
||||
|
|
|
@ -276,6 +276,7 @@ class Atom extends Model
|
|||
@emitError(error)
|
||||
|
||||
emitError: (error) ->
|
||||
console.error(error) unless @inSpecMode()
|
||||
eventObject = {message: error.message, originalError: error}
|
||||
@emitter.emit('will-throw-error', eventObject)
|
||||
@emit('uncaught-error', error.message, null, null, null, error)
|
||||
|
|
|
@ -46,9 +46,12 @@ class DraftChangeSet
|
|||
clearTimeout(@_timer) if @_timer
|
||||
@_timer = setTimeout(@commit, 5000)
|
||||
|
||||
commit: =>
|
||||
# If force is true, then we'll always run the `_onCommit` callback
|
||||
# regardless if there are _pending changes or not
|
||||
commit: ({force}={}) =>
|
||||
@_commitChain = @_commitChain.finally =>
|
||||
if Object.keys(@_pending).length is 0
|
||||
|
||||
if not force and Object.keys(@_pending).length is 0
|
||||
return Promise.resolve(true)
|
||||
|
||||
@_saving = @_pending
|
||||
|
|
|
@ -470,7 +470,15 @@ class DraftStore
|
|||
@_runExtensionsBeforeSend(session)
|
||||
|
||||
# Immediately save any pending changes so we don't save after sending
|
||||
session.changes.commit().then =>
|
||||
#
|
||||
# It's important that we force commit the changes before sending.
|
||||
# Once committed, we'll queue a `SyncbackDraftTask`. Since we may be
|
||||
# sending a draft by its serverId, we need to make sure that the
|
||||
# server has the latest changes. It's possible for the
|
||||
# session.changes._pending to be empty if the last SyncbackDraftTask
|
||||
# failed during its performRemote. When we send we should always try
|
||||
# again.
|
||||
session.changes.commit(force: true).then =>
|
||||
task = new SendDraftTask(draftClientId, {fromPopout: @_isPopout()})
|
||||
Actions.queueTask(task)
|
||||
@_doneWithSession(session)
|
||||
|
@ -495,7 +503,10 @@ class DraftStore
|
|||
@_draftsSending[draftClientId] = false
|
||||
@trigger(draftClientId)
|
||||
if atom.isMainWindow()
|
||||
_.defer ->
|
||||
# We delay so the view has time to update the restored draft. If we
|
||||
# don't delay the modal may come up in a state where the draft looks
|
||||
# like it hasn't been restored or has been lost.
|
||||
_.delay ->
|
||||
remote = require('remote')
|
||||
dialog = remote.require('dialog')
|
||||
dialog.showMessageBox remote.getCurrentWindow(), {
|
||||
|
@ -504,5 +515,6 @@ class DraftStore
|
|||
message: "Error"
|
||||
detail: errorMessage
|
||||
}
|
||||
, 100
|
||||
|
||||
module.exports = new DraftStore()
|
||||
|
|
|
@ -143,6 +143,11 @@ class TaskQueue
|
|||
@_completed.shift() if @_completed.length > 1000
|
||||
@_updateSoon()
|
||||
|
||||
dequeueTaskAndDependents: (taskOrId) ->
|
||||
task = @_resolveTaskArgument(taskOrId)
|
||||
if not task
|
||||
throw new Error("Couldn't find task in queue to dequeue")
|
||||
|
||||
dequeueAll: =>
|
||||
for task in @_queue by -1
|
||||
@dequeue(task)
|
||||
|
@ -164,8 +169,11 @@ class TaskQueue
|
|||
|
||||
_processQueue: =>
|
||||
for task in @_queue by -1
|
||||
continue if @_taskIsBlocked(task)
|
||||
@_processTask(task)
|
||||
if @_taskIsBlocked(task)
|
||||
task.queueState.debugStatus = Task.DebugStatus.WaitingOnDependency
|
||||
continue
|
||||
else
|
||||
@_processTask(task)
|
||||
|
||||
_processTask: (task) =>
|
||||
return if task.queueState.isProcessing
|
||||
|
@ -178,8 +186,50 @@ class TaskQueue
|
|||
.then (status) =>
|
||||
@dequeue(task) unless status is Task.Status.Retry
|
||||
.catch (err) =>
|
||||
console.warn("Task #{task.constructor.name} threw an error: #{err}.")
|
||||
@dequeue(task)
|
||||
@_seenDownstream = {}
|
||||
@_notifyOfDependentError(task, err)
|
||||
.then (responses) =>
|
||||
@_dequeueDownstreamTasks(responses)
|
||||
@dequeue(task)
|
||||
|
||||
# When we `_notifyOfDependentError`s, we collect a nested array of
|
||||
# responses of the tasks we notified. We need to responses to determine
|
||||
# whether or not we should dequeue that task.
|
||||
_dequeueDownstreamTasks: (responses=[]) ->
|
||||
# Responses are nested arrays due to the recursion
|
||||
responses = _.flatten(responses)
|
||||
|
||||
# A response may be `null` if it hit our infinite recursion check.
|
||||
responses = _.filter responses, (r) -> r?
|
||||
|
||||
responses.forEach (resp) =>
|
||||
if resp.returnValue is Task.DO_NOT_DEQUEUE_ME
|
||||
return
|
||||
else
|
||||
resp.downstreamTask.queueState.status = Task.Status.Continue
|
||||
resp.downstreamTask.queueState.debugStatus = Task.DebugStatus.DequeuedDependency
|
||||
@dequeue(resp.downstreamTask)
|
||||
|
||||
# Recursively notifies tasks of dependent errors
|
||||
_notifyOfDependentError: (failedTask, err) ->
|
||||
downstream = @_tasksDependingOn(failedTask) ? []
|
||||
Promise.map downstream, (downstreamTask) =>
|
||||
|
||||
return Promise.resolve(null) unless downstreamTask
|
||||
|
||||
# Infinte recursion check!
|
||||
# These will get removed later
|
||||
return Promise.resolve(null) if @_seenDownstream[downstreamTask.id]
|
||||
@_seenDownstream[downstreamTask.id] = true
|
||||
|
||||
responseHash = Promise.props
|
||||
returnValue: downstreamTask.onDependentTaskError(failedTask, err)
|
||||
downstreamTask: downstreamTask
|
||||
|
||||
return Promise.all([
|
||||
responseHash
|
||||
@_notifyOfDependentError(downstreamTask, err)
|
||||
])
|
||||
|
||||
_dequeueObsoleteTasks: (task) =>
|
||||
obsolete = _.filter @_queue, (otherTask) =>
|
||||
|
@ -191,11 +241,17 @@ class TaskQueue
|
|||
return task.shouldDequeueOtherTask(otherTask)
|
||||
|
||||
for otherTask in obsolete
|
||||
otherTask.queueState.status = Task.Status.Continue
|
||||
otherTask.queueState.debugStatus = Task.DebugStatus.DequeuedObsolete
|
||||
@dequeue(otherTask)
|
||||
|
||||
_tasksDependingOn: (task) ->
|
||||
_.filter @_queue, (otherTask) ->
|
||||
otherTask.isDependentTask(task) and task isnt otherTask
|
||||
|
||||
_taskIsBlocked: (task) =>
|
||||
_.any @_queue, (otherTask) ->
|
||||
task.shouldWaitForTask(otherTask) and task isnt otherTask
|
||||
task.isDependentTask(otherTask) and task isnt otherTask
|
||||
|
||||
_resolveTaskArgument: (taskOrId) =>
|
||||
if not taskOrId
|
||||
|
|
|
@ -41,7 +41,7 @@ class ChangeFolderTask extends ChangeMailTask
|
|||
else
|
||||
return "Moved objects#{folderText}"
|
||||
|
||||
shouldWaitForTask: (other) -> other instanceof SyncbackCategoryTask
|
||||
isDependentTask: (other) -> other instanceof SyncbackCategoryTask
|
||||
|
||||
performLocal: ->
|
||||
if not @folder
|
||||
|
|
|
@ -33,7 +33,7 @@ class ChangeLabelsTask extends ChangeMailTask
|
|||
return "Removed #{@labelsToRemove[0].displayName} from #{@threads.length} #{type}"
|
||||
return "Changed labels on #{@threads.length} #{type}"
|
||||
|
||||
shouldWaitForTask: (other) -> other instanceof SyncbackCategoryTask
|
||||
isDependentTask: (other) -> other instanceof SyncbackCategoryTask
|
||||
|
||||
performLocal: ->
|
||||
if @labelsToAdd.length is 0 and @labelsToRemove.length is 0
|
||||
|
|
|
@ -156,13 +156,13 @@ class ChangeMailTask extends Task
|
|||
performRemote: ->
|
||||
@performRequests(@objectClass(), @objectArray()).then =>
|
||||
@_ensureLocksRemoved()
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch APIError, (err) =>
|
||||
if err.statusCode in NylasAPI.PermanentErrorCodes
|
||||
@_isReverting = true
|
||||
@performLocal().then =>
|
||||
@_ensureLocksRemoved()
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Failed)
|
||||
else
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
||||
|
@ -198,9 +198,11 @@ class ChangeMailTask extends Task
|
|||
|
||||
createUndoTask: ->
|
||||
if @_isUndoTask
|
||||
throw new Error("ChangeMailTask::createUndoTask Cannot create an undo task from an undo task.")
|
||||
err new Error("ChangeMailTask::createUndoTask Cannot create an undo task from an undo task.")
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
if not @_restoreValues
|
||||
throw new Error("ChangeMailTask::createUndoTask Cannot undo a task which has not finished performLocal yet.")
|
||||
err new Error("ChangeMailTask::createUndoTask Cannot undo a task which has not finished performLocal yet.")
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
task = @createIdenticalTask()
|
||||
task._restoreValues = @_restoreValues
|
||||
|
@ -238,7 +240,7 @@ class ChangeMailTask extends Task
|
|||
# To ensure that complex offline actions are synced correctly, label/folder additions
|
||||
# and removals need to be applied in order. (For example, star many threads,
|
||||
# and then unstar one.)
|
||||
shouldWaitForTask: (other) ->
|
||||
isDependentTask: (other) ->
|
||||
# Only wait on other tasks that are older and also involve the same threads
|
||||
return unless other instanceof ChangeMailTask
|
||||
otherOlder = other.creationDate < @creationDate
|
||||
|
|
|
@ -40,12 +40,12 @@ class CreateMetadataTask extends Task
|
|||
value: @value
|
||||
success: =>
|
||||
Actions.metadataCreated @type, @metadatum
|
||||
resolve(Task.Status.Finished)
|
||||
resolve(Task.Status.Success)
|
||||
error: (apiError) =>
|
||||
Actions.metadataError _.extend @_baseErrorData(),
|
||||
errorType: "APIError"
|
||||
error: apiError
|
||||
reject(apiError)
|
||||
resolve(Task.Status.Failed)
|
||||
|
||||
_baseErrorData: ->
|
||||
action: "create"
|
||||
|
|
|
@ -24,7 +24,7 @@ class DestroyDraftTask extends Task
|
|||
else
|
||||
false
|
||||
|
||||
shouldWaitForTask: (other) ->
|
||||
isDependentTask: (other) ->
|
||||
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId)
|
||||
|
||||
performLocal: ->
|
||||
|
@ -44,8 +44,13 @@ class DestroyDraftTask extends Task
|
|||
# We don't need to do anything if we weren't able to find the draft
|
||||
# when we performed locally, or if the draft has never been synced to
|
||||
# the server (id is still self-assigned)
|
||||
return Promise.resolve(Task.Status.Finished) unless @draft
|
||||
return Promise.resolve(Task.Status.Finished) unless @draft.serverId and @draft.version?
|
||||
if not @draft
|
||||
err new Error("No valid draft to destroy!")
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
if not @draft.serverId or not @draft.version?
|
||||
err new Error("Can't destroy draft without a version or serverId")
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
NylasAPI.makeRequest
|
||||
path: "/drafts/#{@draft.serverId}"
|
||||
|
@ -55,21 +60,21 @@ class DestroyDraftTask extends Task
|
|||
version: @draft.version
|
||||
returnsModel: false
|
||||
.then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch APIError, (err) =>
|
||||
inboxMsg = err.body?.message ? ""
|
||||
|
||||
# Draft has already been deleted, this is not really an error
|
||||
if err.statusCode in [404, 409]
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Continue)
|
||||
|
||||
# Draft has been sent, and can't be deleted. Not much we can do but finish
|
||||
if inboxMsg.indexOf("is not a draft") >= 0
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Continue)
|
||||
|
||||
if err.statusCode in NylasAPI.PermanentErrorCodes
|
||||
Actions.postNotification({message: "Unable to delete this draft. Restoring...", type: "error"})
|
||||
DatabaseStore.persistModel(@draft).then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
|
||||
Promise.resolve(Task.Status.Retry)
|
||||
Promise.resolve(Task.Status.Failed)
|
||||
else
|
||||
Promise.resolve(Task.Status.Retry)
|
||||
|
|
|
@ -57,12 +57,12 @@ class DestroyMetadataTask extends Task
|
|||
body: body
|
||||
success: =>
|
||||
Actions.metadataDestroyed(@type)
|
||||
resolve(Task.Status.Finished)
|
||||
resolve(Task.Status.Success)
|
||||
error: (apiError) =>
|
||||
Actions.metadataError _.extend @_baseErrorData(),
|
||||
errorType: "APIError"
|
||||
error: apiError
|
||||
reject(apiError)
|
||||
resolve(Task.Status.Failed)
|
||||
|
||||
_baseErrorData: ->
|
||||
action: "destroy"
|
||||
|
|
|
@ -39,11 +39,14 @@ class EventRSVPTask extends Task
|
|||
}
|
||||
returnsModel: true
|
||||
.then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch APIError, (err) =>
|
||||
##TODO: event already accepted/declined/etc
|
||||
@event.participants = @_previousParticipantsState
|
||||
DatabaseStore.persistModel(@event).then(resolve).catch(reject)
|
||||
DatabaseStore.persistModel(@event).then ->
|
||||
resolve(Task.Status.Failed)
|
||||
.catch (err) ->
|
||||
resolve(Task.Status.Failed)
|
||||
|
||||
onOtherError: -> Promise.resolve()
|
||||
onTimeoutError: -> Promise.resolve()
|
||||
|
|
|
@ -35,7 +35,10 @@ class FileUploadTask extends Task
|
|||
Actions.uploadStateChanged @_uploadData("started")
|
||||
|
||||
DatabaseStore.findBy(Message, {clientId: @messageClientId}).then (draft) =>
|
||||
return Promise.resolve(Task.Status.Finished) unless draft
|
||||
if not draft
|
||||
err new Error("Can't find draft #{@messageClientId} in Database to upload file to")
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
@_accountId = draft.accountId
|
||||
|
||||
@_makeRequest()
|
||||
|
@ -44,17 +47,17 @@ class FileUploadTask extends Task
|
|||
.then (file) =>
|
||||
Actions.uploadStateChanged @_uploadData("completed")
|
||||
Actions.fileUploaded(file: file, uploadData: @_uploadData("completed"))
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch APIError, (err) =>
|
||||
if err.statusCode in NylasAPI.PermanentErrorCodes
|
||||
msg = "There was a problem uploading this file. Please try again later."
|
||||
Actions.uploadStateChanged(@_uploadData("failed"))
|
||||
Actions.postNotification({message: msg, type: "error"})
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
else if err.statusCode is NylasAPI.CancelledErrorCode
|
||||
Actions.uploadStateChanged(@_uploadData("aborted"))
|
||||
Actions.fileAborted(@_uploadData("aborted"))
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Failed)
|
||||
else
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
||||
|
|
|
@ -29,12 +29,12 @@ class MarkMessageReadTask extends Task
|
|||
unread: false
|
||||
returnsModel: true
|
||||
.then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch APIError, (err) =>
|
||||
if err.statusCode in NylasAPI.PermanentErrorCodes
|
||||
# Run performLocal backwards to undo the tag changes
|
||||
@message.unread = @_previousUnreadState
|
||||
DatabaseStore.persistModel(@message).then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Failed)
|
||||
else
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
|
|
@ -21,10 +21,17 @@ class SendDraftTask extends Task
|
|||
shouldDequeueOtherTask: (other) ->
|
||||
other instanceof SendDraftTask and other.draftClientId is @draftClientId
|
||||
|
||||
shouldWaitForTask: (other) ->
|
||||
isDependentTask: (other) ->
|
||||
(other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId) or
|
||||
(other instanceof FileUploadTask and other.messageClientId is @draftClientId)
|
||||
|
||||
onDependentTaskError: (task, err) ->
|
||||
if task instanceof SyncbackDraftTask
|
||||
msg = "Your message could not be sent because we could not save your draft. Please check your network connection and try again soon."
|
||||
else if task instanceof FileUploadTask
|
||||
msg = "Your message could not be sent because a file failed to upload. Please try re-uploading your file and try again."
|
||||
@_notifyUserOfError(msg) if msg
|
||||
|
||||
performLocal: ->
|
||||
# When we send drafts, we don't update anything in the app until
|
||||
# it actually succeeds. We don't want users to think messages have
|
||||
|
@ -42,6 +49,11 @@ class SendDraftTask extends Task
|
|||
if not draft
|
||||
return Promise.reject(new Error("We couldn't find the saved draft."))
|
||||
|
||||
# Just before sending we ask the {DraftStoreProxy} to commit its
|
||||
# changes. This will fire a {SyncbackDraftTask}. Since we will be
|
||||
# sending the draft by its serverId, we must be ABSOLUTELY sure that
|
||||
# the {SyncbackDraftTask} succeeded otherwise we will send an
|
||||
# incomplete or obsolete message.
|
||||
if draft.serverId
|
||||
body =
|
||||
draft_id: draft.serverId
|
||||
|
@ -81,7 +93,7 @@ class SendDraftTask extends Task
|
|||
draftClientId: @draftClientId
|
||||
newMessage: @draft
|
||||
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch @_permanentError
|
||||
|
||||
.catch APIError, (err) =>
|
||||
|
@ -92,16 +104,26 @@ class SendDraftTask extends Task
|
|||
body.thread_id = null
|
||||
body.reply_to_message_id = null
|
||||
return @_send(body)
|
||||
else if (err.statusCode in NylasAPI.PermanentErrorCodes or
|
||||
err.statusCode is NylasAPI.TimeoutErrorCode)
|
||||
@_permanentError()
|
||||
else if err.statusCode is 500
|
||||
msg = "Your message could not be sent at this time. Please try again soon."
|
||||
return @_permanentError(err, msg)
|
||||
else if err.statusCode in [400, 404]
|
||||
msg = "Your message could not be sent at this time. Please try again soon."
|
||||
atom.emitError(new Error("Sending a message responded with #{err.statusCode}!"))
|
||||
return @_permanentError(err, msg)
|
||||
else if err.statusCode is NylasAPI.TimeoutErrorCode
|
||||
msg = "We lost internet connection just as we were trying to send your message! Please wait a little bit to see if it went through. If not, check your internet connection and try sending again."
|
||||
return @_permanentError(err, msg)
|
||||
else
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
||||
_permanentError: =>
|
||||
msg = "Your draft could not be sent. Please check your network connection and try again."
|
||||
_permanentError: (err, msg) =>
|
||||
@_notifyUserOfError(msg)
|
||||
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
_notifyUserOfError: (msg) =>
|
||||
if @fromPopout
|
||||
Actions.composePopoutDraft(@draftClientId, {errorMessage: msg})
|
||||
else
|
||||
Actions.draftSendingFailed({draftClientId: @draftClientId, errorMessage: msg})
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
|
|
|
@ -48,12 +48,12 @@ module.exports = class SyncbackCategoryTask extends Task
|
|||
@category.serverId = json.id
|
||||
DatabaseStore.persistModel @category
|
||||
.then ->
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
.catch APIError, (err) =>
|
||||
if err.statusCode in NylasAPI.PermanentErrorCodes
|
||||
@_isReverting = true
|
||||
@performLocal().then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Failed)
|
||||
else
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
||||
|
|
|
@ -22,10 +22,19 @@ class SyncbackDraftTask extends Task
|
|||
super
|
||||
|
||||
shouldDequeueOtherTask: (other) ->
|
||||
other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId and other.creationDate < @creationDate
|
||||
other instanceof SyncbackDraftTask and
|
||||
other.draftClientId is @draftClientId and
|
||||
other.creationDate <= @creationDate
|
||||
|
||||
shouldWaitForTask: (other) ->
|
||||
other instanceof SyncbackDraftTask and other.draftClientId is @draftClientId and other.creationDate < @creationDate
|
||||
isDependentTask: (other) ->
|
||||
other instanceof SyncbackDraftTask and
|
||||
other.draftClientId is @draftClientId and
|
||||
other.creationDate <= @creationDate
|
||||
|
||||
# We want to wait for other SyncbackDraftTasks to run, but we don't want
|
||||
# to get dequeued if they fail.
|
||||
onDependentTaskError: ->
|
||||
return Task.DO_NOT_DEQUEUE_ME
|
||||
|
||||
performLocal: ->
|
||||
# SyncbackDraftTask does not do anything locally. You should persist your changes
|
||||
|
@ -84,7 +93,7 @@ class SyncbackDraftTask extends Task
|
|||
DatabaseStore.persistModel(draft)
|
||||
|
||||
.then =>
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
return Promise.resolve(Task.Status.Success)
|
||||
|
||||
.catch APIError, (err) =>
|
||||
if err.statusCode in [400, 404, 409] and err.requestOptions?.method is 'PUT'
|
||||
|
@ -92,12 +101,13 @@ class SyncbackDraftTask extends Task
|
|||
if not draft then draft = oldDraft
|
||||
@detatchFromRemoteID(draft).then ->
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
|
||||
else if err.statusCode in NylasAPI.PermanentErrorCodes
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
|
||||
else
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
# NOTE: There's no offline handling. If we're offline
|
||||
# SyncbackDraftTasks should always fail.
|
||||
#
|
||||
# We don't roll anything back locally, but this failure
|
||||
# ensures that SendDraftTasks can never succeed while offline.
|
||||
Promise.resolve([Task.Status.Failed, err])
|
||||
|
||||
getLatestLocalDraft: =>
|
||||
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body)
|
||||
|
|
|
@ -1,53 +1,163 @@
|
|||
_ = require 'underscore'
|
||||
{generateTempId} = require '../models/utils'
|
||||
|
||||
TaskStatus =
|
||||
Finished: 'finished'
|
||||
Retry: 'retry'
|
||||
Retry: "RETRY"
|
||||
Success: "SUCCESS"
|
||||
Continue: "CONTINUE"
|
||||
Failed: "FAILED"
|
||||
|
||||
# Public: Tasks represent individual changes to the datastore that
|
||||
# alter the local cache and need to be synced back to the server.
|
||||
TaskDebugStatus =
|
||||
JustConstructed: "JUST CONSTRUCTED"
|
||||
UncaughtError: "UNCAUGHT ERROR"
|
||||
DequeuedObsolete: "DEQUEUED (Obsolete)"
|
||||
DequeuedDependency: "DEQUEUED (Dependency Failure)"
|
||||
WaitingOnQueue: "WAITING ON QUEUE"
|
||||
WaitingOnDependency: "WAITING ON DEPENDENCY"
|
||||
RunningLocal: "RUNNING LOCAL"
|
||||
ProcessingRemote: "PROCESSING REMOTE"
|
||||
|
||||
# Public: Tasks are a robust way to handle any mutating changes that need
|
||||
# to interface with a remote API.
|
||||
#
|
||||
# To create a new task, subclass Task and implement the following methods:
|
||||
# Tasks help you handle and encapsulate optimistic updates, rollbacks,
|
||||
# undo/redo, API responses, API errors, queuing, and multi-step
|
||||
# dependencies.
|
||||
#
|
||||
# - performLocal:
|
||||
# Return a {Promise} that does work immediately. Must resolve or the task
|
||||
# will be thrown out. Generally, you should optimistically update
|
||||
# the local cache here.
|
||||
# They are especially useful in offline mode. Users may have taken tons of
|
||||
# actions that we've queued up to process when they come back online.
|
||||
#
|
||||
# - performRemote:
|
||||
# Do work that requires dependencies to have resolved and may need to be
|
||||
# tried multiple times to succeed in case of network issues.
|
||||
# Tasks represent individual changes to the datastore that alter the local
|
||||
# cache and need to be synced back to the server.
|
||||
#
|
||||
# performRemote must return a {Promise}, and it should always resolve with
|
||||
# `Task.Status.Finished` or `Task.Status.Retry`. Rejections are considered
|
||||
# exception cases and are logged to our server.
|
||||
# To create your own task, subclass Task and implement the following
|
||||
# required methods:
|
||||
#
|
||||
# Returning `Task.Status.Retry` will cause the `TaskQueue` to leave your task
|
||||
# on the queue and run it again later. You should only return `Task.Status.Retry`
|
||||
# if your task encountered a transient error (for example, a `0` but not a `400`).
|
||||
# - {Task::performLocal}
|
||||
# - {Task::performRemote}
|
||||
#
|
||||
# - shouldWaitForTask:
|
||||
# Tasks may be arbitrarily dependent on other tasks. To ensure that
|
||||
# `performRemote` is called at the right time, subclasses should implement
|
||||
# `shouldWaitForTask(other)`. For example, the `SendDraft` task is dependent
|
||||
# on the draft's files' `UploadFile` tasks completing.
|
||||
# See their usage in the documentation below.
|
||||
#
|
||||
# Tasks may also implement `shouldDequeueOtherTask(other)`. Returning true
|
||||
# will cause the other event to be removed from the queue. This is useful in
|
||||
# offline mode especially, when the user might `Save`,`Save`,`Save`,`Save`,`Send`.
|
||||
# Each newly queued `Save` can cancel the (unstarted) save task in the queue.
|
||||
# ## Task Dependencies
|
||||
#
|
||||
# Tasks that need to support undo/redo should implement `canBeUndone`, `isUndo`,
|
||||
# `createUndoTask`, and `createIdenticalTask`.
|
||||
# The Task system handles dependencies between multiple queued tasks. For
|
||||
# example, the {SendDraftTask} has a dependency on the {SyncbackDraftTask}
|
||||
# (aka saving) succeeding. To establish dependencies between tasks, your
|
||||
# subclass may implement one or more of the following methods:
|
||||
#
|
||||
# - {Task::isDependentTask}
|
||||
# - {Task::onDependentTaskError}
|
||||
# - {Task::shouldDequeueOtherTask}
|
||||
#
|
||||
# ## Undo / Redo
|
||||
#
|
||||
# The Task system also supports undo/redo handling. Your subclass must
|
||||
# implement the following methods to enable this:
|
||||
#
|
||||
# - {Task::isUndo}
|
||||
# - {Task::canBeUndone}
|
||||
# - {Task::createUndoTask}
|
||||
# - {Task::createIdenticalTask}
|
||||
#
|
||||
# ## Offline Considerations
|
||||
#
|
||||
# All tasks should gracefully handle the case when there is no network
|
||||
# connection.
|
||||
#
|
||||
# If we're offline the common behavior is for a task to:
|
||||
#
|
||||
# 1. Perform its local change
|
||||
# 2. Attempt the remote request and get a timeout or offline code
|
||||
# 3. Have `performRemote` resolve a `Task.Status.Retry`
|
||||
# 3. Sit queued up waiting to be retried
|
||||
# 4. Wait for {Actions::longPollConnected} to restart the {TaskQueue}
|
||||
#
|
||||
# Remember that a user may be offline for hours and perform thousands of
|
||||
# tasks in the meantime. It's important that your tasks implement
|
||||
# `shouldDequeueOtherTask` and `isDependentTask` to make sure ordering
|
||||
# always remains correct.
|
||||
#
|
||||
# ## Serialization and Window Considerations
|
||||
#
|
||||
# The whole {TaskQueue} and all of its Tasks are serialized and stored in
|
||||
# the Database. This allows the {TaskQueue} to work across windows and
|
||||
# ensures we don't lose any pending tasks if a user is offline for a while
|
||||
# and quits and relaunches the application.
|
||||
#
|
||||
# All instance variables you create must be able to be serialized to a
|
||||
# JSON string and re-inflated. Notably, **`function` objects will not be
|
||||
# properly re-inflated**.
|
||||
#
|
||||
# If you have instance variables that are instances of core {Model}
|
||||
# classes or {Task} classes, they will be automatically re-inflated to the
|
||||
# correct class via {Utils::deserializeRegisteredObject}. If you create
|
||||
# your own custom classes, they must be registered once per window via
|
||||
# {TaskRegistry::register}
|
||||
#
|
||||
# ## Example Task
|
||||
#
|
||||
# **Task Definition**:
|
||||
#
|
||||
# ```coffee
|
||||
# _ = require 'underscore'
|
||||
# request = require 'request'
|
||||
# {Task, DatabaseStore} = require('nylas-exports')
|
||||
#
|
||||
# class UpdateTodoTask extends Task
|
||||
# constructor: (@existingTodo, @newData) ->
|
||||
# super
|
||||
#
|
||||
# performLocal: ->
|
||||
# @updatedTodo = _.extend(_.clone(@existingTodo), @newData)
|
||||
# return DatabaseStore.persistModel(@updatedTodo)
|
||||
#
|
||||
# performRemote: ->
|
||||
# new Promise (resolve, reject) =>
|
||||
# options = {url: "https://myapi.co", method: 'PUT', json: @newData}
|
||||
# request options, (error, response, body) ->
|
||||
# if error then resolve(Task.Status.Failed)
|
||||
# else resolve(Task.Status.Success)
|
||||
#
|
||||
# module.exports = UpdateTodoTask
|
||||
# ```
|
||||
#
|
||||
# **Task Usage**:
|
||||
#
|
||||
# ```coffee
|
||||
# {Actions} = require('nylas-exports')
|
||||
# UpdateTodoTask = require('./update-todo-task')
|
||||
#
|
||||
# someMethod: ->
|
||||
# ...
|
||||
#
|
||||
# task = new UpdateTodoTask(existingTodo, name: "Test")
|
||||
# Actions.queueTask(task)
|
||||
#
|
||||
# ...
|
||||
#
|
||||
# ```
|
||||
#
|
||||
# This example `UpdateTodoTask` does not handle undo/redo, nor does it
|
||||
# rollback the changes if there's an API error. See examples in
|
||||
# {Task::performLocal} for ideas on how to handle this.
|
||||
#
|
||||
class Task
|
||||
|
||||
@Status: TaskStatus
|
||||
@DebugStatus: TaskDebugStatus
|
||||
|
||||
# A constant that can be returned by `onDependentTaskError` to prevent
|
||||
# this task from being dequeued
|
||||
@DO_NOT_DEQUEUE_ME = "DO_NOT_DEQUEUE_ME"
|
||||
|
||||
# Public: Override the constructor to pass initial args to your Task and
|
||||
# initialize instance variables.
|
||||
#
|
||||
# **IMPORTANT:** If you override the constructor, be sure to call
|
||||
# `super`.
|
||||
#
|
||||
# On construction, all Tasks instances are given a unique `id`.
|
||||
constructor: ->
|
||||
@_rememberedToCallSuper = true
|
||||
|
||||
@id = generateTempId()
|
||||
@creationDate = new Date()
|
||||
@queueState =
|
||||
|
@ -57,8 +167,11 @@ class Task
|
|||
remoteError: null
|
||||
remoteAttempts: 0
|
||||
remoteComplete: false
|
||||
status: null
|
||||
debugStatus: Task.DebugStatus.JustConstructed
|
||||
@
|
||||
|
||||
# Private: This is a internal wrapper around `performLocal`
|
||||
runLocal: ->
|
||||
if not @_rememberedToCallSuper
|
||||
throw new Error("Your must call `super` from your Task's constructors")
|
||||
|
@ -66,72 +179,350 @@ class Task
|
|||
if @queueState.localComplete
|
||||
return Promise.resolve()
|
||||
else
|
||||
@performLocal()
|
||||
.then =>
|
||||
@queueState.localComplete = true
|
||||
@queueState.localError = null
|
||||
return Promise.resolve()
|
||||
.catch (err) =>
|
||||
@queueState.localError = err
|
||||
return Promise.reject(err)
|
||||
@queueState.debugStatus = Task.DebugStatus.RunningLocal
|
||||
try
|
||||
@performLocal()
|
||||
.then =>
|
||||
@queueState.localComplete = true
|
||||
@queueState.localError = null
|
||||
@queueState.debugStatus = Task.DebugStatus.WaitingOnQueue
|
||||
return Promise.resolve()
|
||||
.catch(@_handleLocalError)
|
||||
catch err
|
||||
return @_handleLocalError(err)
|
||||
|
||||
_handleLocalError: (err) =>
|
||||
@queueState.localError = err
|
||||
@queueState.status = Task.Status.Failed
|
||||
@queueState.debugStatus = Task.DebugStatus.UncaughtError
|
||||
atom.emitError(err)
|
||||
return Promise.reject(err)
|
||||
|
||||
# Private: This is an internal wrapper around `performRemote`
|
||||
runRemote: ->
|
||||
@queueState.debugStatus = Task.DebugStatus.ProcessingRemote
|
||||
|
||||
if @queueState.localComplete is false
|
||||
throw new Error("runRemote called before performLocal complete, this is an assertion failure.")
|
||||
|
||||
if @queueState.remoteComplete
|
||||
return Promise.resolve(Task.Status.Finished)
|
||||
@queueState.status = Task.Status.Continue
|
||||
return Promise.resolve(Task.Status.Continue)
|
||||
|
||||
@performRemote()
|
||||
.catch (err) =>
|
||||
@queueState.remoteAttempts += 1
|
||||
@queueState.remoteError = err
|
||||
.then (status) =>
|
||||
if not (status in _.values(Task.Status))
|
||||
throw new Error("performRemote returned #{status}, which is not a Task.Status")
|
||||
@queueState.remoteAttempts += 1
|
||||
@queueState.remoteComplete = status is Task.Status.Finished
|
||||
@queueState.remoteError = null
|
||||
return Promise.resolve(status)
|
||||
try
|
||||
@performRemote()
|
||||
.then (compositeStatus) =>
|
||||
[status, err] = @_compositeStatus(compositeStatus)
|
||||
|
||||
if status is Task.Status.Failed
|
||||
# We reject here to end up on the same path as people who may
|
||||
# have manually `reject`ed the promise
|
||||
return Promise.reject(compositeStatus)
|
||||
|
||||
## Everything beneath here may be overridden in subclasses ##
|
||||
@queueState.status = status
|
||||
@queueState.remoteAttempts += 1
|
||||
@queueState.remoteComplete = status in [Task.Status.Success, Task.Status.Continue]
|
||||
@queueState.remoteError = null
|
||||
return Promise.resolve(status)
|
||||
.catch (compositeStatus) =>
|
||||
[status, err] = @_compositeStatus(compositeStatus)
|
||||
@_handleRemoteError(err, status)
|
||||
catch err
|
||||
return @_handleRemoteError(err)
|
||||
|
||||
# performLocal is called once when the task is queued. You must return
|
||||
# a promise. If you resolve, the task is queued and performRemote will
|
||||
# be called. If you reject, the task will not be queued.
|
||||
# When resolving from performRemote, people can resolve one of the
|
||||
# `Task.Status` constants. In the case of `Task.Status.Failed`, they can
|
||||
# return an array with the constant as the first item and the error
|
||||
# object as the second item. We are also resilient to accidentally
|
||||
# getting passed malformed values or error objects.
|
||||
#
|
||||
# This always returns in the form of `[status, err]`
|
||||
_compositeStatus: (compositeStatus) ->
|
||||
if compositeStatus instanceof Error
|
||||
return [Task.Status.Failed, compositeStatus]
|
||||
else if _.isString(compositeStatus)
|
||||
if compositeStatus in _.values(Task.Status)
|
||||
return [compositeStatus, null]
|
||||
else
|
||||
err = new Error("performRemote returned #{compositeStatus}, which is not a Task.Status")
|
||||
return [Task.Status.Failed, err]
|
||||
else if _.isArray(compositeStatus)
|
||||
status = compositeStatus[0]
|
||||
err = compositeStatus[1]
|
||||
return [status, err]
|
||||
else
|
||||
err = new Error("performRemote returned #{compositeStatus}, which is not a Task.Status")
|
||||
return [Task.Status.Failed, err]
|
||||
|
||||
_handleRemoteError: (err, status) =>
|
||||
# Sometimes users just indicate that a task Failed, but don't provide
|
||||
# the error object
|
||||
err ?= new Error("Unexpected remote error in #{Task.constructor.name}")
|
||||
|
||||
if status isnt Task.Status.Failed
|
||||
@queueState.debugStatus = Task.DebugStatus.UncaughtError
|
||||
atom.emitError(err)
|
||||
|
||||
@queueState.status = Task.Status.Failed
|
||||
@queueState.remoteAttempts += 1
|
||||
@queueState.remoteError = err
|
||||
|
||||
return Promise.reject(err)
|
||||
|
||||
########################################################################
|
||||
######################## METHODS TO OVERRIDE ###########################
|
||||
########################################################################
|
||||
|
||||
##### REQUIRED METHODS #####
|
||||
|
||||
# Public: **Required** | Override to perform local, optimistic updates.
|
||||
#
|
||||
# Most tasks will put code in here that updates the {DatabaseStore}
|
||||
#
|
||||
# You should also implement the rollback behavior inside of
|
||||
# `performLocal` or in some helper method. It's common practice (but not
|
||||
# automatic) for `performLocal` to be re-called at the end of an API
|
||||
# failure from `performRemote`.
|
||||
#
|
||||
# That rollback behavior is also likely the same when you want to undo a
|
||||
# task. It's common practice (but not automatic) for `createUndoTask` to
|
||||
# set some flag that `performLocal` will recognize to implement the
|
||||
# rollback behavior.
|
||||
#
|
||||
# `performLocal` will complete BEFORE the task actually enters the
|
||||
# {TaskQueue}.
|
||||
#
|
||||
# If you would like to do work after `performLocal` has run, you can use
|
||||
# {TaskQueueStatusStore::waitForPerformLocal}. Pass it the task and it
|
||||
# will return a Promise that resolves once the local action has
|
||||
# completed. This is contained in the {TaskQueueStatusStore} so you can
|
||||
# listen to tasks across windows.
|
||||
#
|
||||
# ## Examples:
|
||||
#
|
||||
# ### Simple Optimistic Updating:
|
||||
#
|
||||
# ```coffee
|
||||
# class MyTask extends Task
|
||||
# performLocal: ->
|
||||
# @updatedModel = @_myModelUpdateCode()
|
||||
# return DatabaseStore.persistModel(@updatedModel)
|
||||
# ```
|
||||
#
|
||||
# ### Handling rollback on API failure
|
||||
#
|
||||
# ```coffee
|
||||
# class MyTask extends Task
|
||||
# performLocal: ->
|
||||
# if @_reverting
|
||||
# @updatedModel = @_myModelRollbackCode()
|
||||
# else
|
||||
# @updatedModel = @_myModelUpdateCode()
|
||||
# return DatabaseStore.persistModel(@updatedModel)
|
||||
#
|
||||
# performRemote: ->
|
||||
# @_APIPutHelperMethod(@updatedModel).catch (apiError) =>
|
||||
# if apiError.statusCode is 500
|
||||
# @_reverting = true
|
||||
# @performLocal()
|
||||
# ```
|
||||
#
|
||||
# ### Handling an undo task
|
||||
#
|
||||
# ```coffee
|
||||
# class MyTask extends Task
|
||||
# performLocal: ->
|
||||
# if @_isUndoTask
|
||||
# @updatedModel = @_myModelRollbackCode()
|
||||
# else
|
||||
# @updatedModel = @_myModelUpdateCode()
|
||||
# return DatabaseStore.persistModel(@updatedModel)
|
||||
#
|
||||
# createUndoTask: ->
|
||||
# undoTask = @createIdenticalTask()
|
||||
# undoTask._isUndoTask = true
|
||||
# return undoTask
|
||||
# ```
|
||||
#
|
||||
# Also see the documentation on the required undo methods
|
||||
#
|
||||
# Returns a {Promise} that resolves when your updates are complete.
|
||||
performLocal: ->
|
||||
Promise.resolve()
|
||||
|
||||
# Public: **Required** | Put the actual API request code here.
|
||||
#
|
||||
# You must return a {Promise} that resolves to one of the following
|
||||
# status constants:
|
||||
#
|
||||
# - `Task.Status.Success`
|
||||
# - `Task.Status.Retry`
|
||||
# - `Task.Status.Continue`
|
||||
# - `Task.Status.Failed`
|
||||
#
|
||||
# The resolved status will determine what the {TaskQueue} does with this
|
||||
# task when it is finished.
|
||||
#
|
||||
# This is where you should put your actual API code. You can use the
|
||||
# node `request` library to easily hit APIs, or use the {NylasAPI} class
|
||||
# to talk to the [Nylas Platform API](https://nylas.com/docs).
|
||||
#
|
||||
# Here is a more detailed explanation of Task Statuses:
|
||||
#
|
||||
# ### Task.Status.Success
|
||||
#
|
||||
# Resolve to `Task.Status.Success` when the task successfully completes.
|
||||
# Once done, the task will be dequeued and logged as a success.
|
||||
#
|
||||
# ### Task.Status.Retry
|
||||
#
|
||||
# If you resolve `Task.Status.Retry`, the task will remain on the queue
|
||||
# and tried again later. Any other task dependent on the current one
|
||||
# will also continue waiting.
|
||||
#
|
||||
# The queue is re-processed whenever a new task is enqueued, dequeued,
|
||||
# or the internet connection comes back online via
|
||||
# {Actions::longPollConnected}.
|
||||
#
|
||||
# `Task.Status.Retry` is useful if it looks like we're offline, or you
|
||||
# get an API error code that indicates temporary failure.
|
||||
#
|
||||
# ### Task.Status.Continue
|
||||
#
|
||||
# Resolving `Task.Status.Continue` will silently dequeue the task, allow
|
||||
# dependent tasks through, but not mark it as successfully resolved.
|
||||
#
|
||||
# This is useful if you get permanent API errors, but don't really care
|
||||
# if the task failed.
|
||||
#
|
||||
# ### Task.Status.Failed
|
||||
#
|
||||
# If you catch a permanent API error code (like a 500), or something
|
||||
# else goes wrong then resolve to `Task.Status.Failed`.
|
||||
#
|
||||
# Resolving `Task.Status.Failed` will dequeue this task, and **dequeue
|
||||
# all dependent tasks**.
|
||||
#
|
||||
# You can optionally return the error object itself for debugging
|
||||
# purposes by resolving an array of the form: `[Task.Status.Failed,
|
||||
# errorObject]`
|
||||
#
|
||||
# You should not `throw` exceptions. Catch all cases yourself and
|
||||
# determine which `Task.Status` to resolve to. If due to programmer
|
||||
# error an exception is thrown, our {TaskQueue} will catch it, log it,
|
||||
# and deal with the task as if it resolved `Task.Status.Failed`.
|
||||
#
|
||||
# Returns a {Promise} that resolves to a valid `Task.Status` type.
|
||||
performRemote: ->
|
||||
Promise.resolve(Task.Status.Finished)
|
||||
Promise.resolve(Task.Status.Success)
|
||||
|
||||
cancel: ->
|
||||
# We ignore requests to cancel and carry on. Subclasses that want to support
|
||||
# cancellation or dequeue requests while running should implement cancel.
|
||||
|
||||
canBeUndone: -> false
|
||||
##### DEPENDENCY METHODS #####
|
||||
|
||||
# Public: determines which other tasks this one is dependent on.
|
||||
#
|
||||
# - `other` An instance of a {Task} you must test to see if it's a
|
||||
# dependency of this one.
|
||||
#
|
||||
# Any task that passes the truth test will be considered a "dependency".
|
||||
#
|
||||
# If a "dependency" has a `Task.Status.Failed`, then all downstream
|
||||
# tasks will get dequeued recursively.
|
||||
#
|
||||
# Returns `true` (is dependent on) or `false` (is not dependent on)
|
||||
isDependentTask: (other) -> false
|
||||
|
||||
# Public: called when a dependency errors out
|
||||
#
|
||||
# - `task` An instance of the dependent {Task} that errored.
|
||||
# - `err` The Error object (if any)
|
||||
#
|
||||
# If a dependent task (anything for which {Task::isDependentTask} returns
|
||||
# true) resolves with `Task.Status.Failed`, then this method will be
|
||||
# called.
|
||||
#
|
||||
# This is an opportunity to cleanup or notify users of the error.
|
||||
#
|
||||
# By default, since a dependency failed, **this task will be dequeued**
|
||||
#
|
||||
# However, if you return the special `Task.DO_NOT_DEQUEUE_ME` constant,
|
||||
# this task will not get dequeued and processed in turn.
|
||||
#
|
||||
# Returns if you return the `Task.DO_NOT_DEQUEUE_ME` constant, then this
|
||||
# task will not get dequeued. Any other return value (including `false`)
|
||||
# will proceed with the default behavior and dequeue this task.
|
||||
onDependentTaskError: (task, err) ->
|
||||
|
||||
# Public: determines which other tasks this one should dequeue.
|
||||
#
|
||||
# - `other` An instance of a {Task} you must test to see if it's now
|
||||
# obsolete.
|
||||
#
|
||||
# Any task that passes the truth test will be considered "obsolete" and
|
||||
# dequeued immediately.
|
||||
#
|
||||
# This is particularly useful in offline mode. Users may queue up tons
|
||||
# of tasks but when we come back online to process them, we only want to
|
||||
# process the latest one.
|
||||
#
|
||||
# Returns `true` (should dequeue) or `false` (should not dequeue)
|
||||
shouldDequeueOtherTask: (other) -> false
|
||||
|
||||
|
||||
##### UNDO / REDO METHODS #####
|
||||
|
||||
# Public: It's up to you to determine how you want to indicate whether
|
||||
# or not you have an instance of an "Undo Task". We commonly use a
|
||||
# simple instance variable boolean flag.
|
||||
#
|
||||
# Returns `true` (is an Undo Task) or `false` (is not an Undo Task)
|
||||
isUndo: -> false
|
||||
|
||||
# Public: Determines whether or not this task can be undone via the
|
||||
# {UndoRedoStore}
|
||||
#
|
||||
# Returns `true` (can be undone) or `false` (can't be undone)
|
||||
canBeUndone: -> false
|
||||
|
||||
# Public: Return from `createIdenticalTask` and set a flag so your
|
||||
# `performLocal` and `performRemote` methods know that this is an undo
|
||||
# task.
|
||||
createUndoTask: -> throw new Error("Unimplemented")
|
||||
|
||||
# Public: Return a deep-cloned task to be used for an undo task
|
||||
createIdenticalTask: ->
|
||||
json = @toJSON()
|
||||
delete json['queueState']
|
||||
(new @.constructor).fromJSON(json)
|
||||
|
||||
|
||||
##### OTHER METHODS #####
|
||||
|
||||
# Public: code to run if someone tries to dequeue your task while it is
|
||||
# in flight.
|
||||
#
|
||||
# For example, the {FileUploadTask} implements `cancel` to `abort` the
|
||||
# http request if someone dequeues it. Once `abort`ed, an error is
|
||||
# thrown in `performRemote` and handled accordingly.
|
||||
cancel: ->
|
||||
|
||||
# Public: (optional) A string displayed to users when your task is run.
|
||||
#
|
||||
# When tasks are run, we automatically display a notification to users
|
||||
# of the form "label (numberOfImpactedItems)". If this does not a return
|
||||
# a string, no notification is displayed
|
||||
label: ->
|
||||
|
||||
# Public: A string displayed to users indicating how many items your
|
||||
# task affected.
|
||||
numberOfImpactedItems: ->
|
||||
1
|
||||
|
||||
shouldDequeueOtherTask: (other) -> false
|
||||
|
||||
shouldWaitForTask: (other) -> false
|
||||
|
||||
# Private: Allows for serialization of tasks
|
||||
toJSON: ->
|
||||
@
|
||||
|
||||
# Private: Allows for deserialization of tasks
|
||||
fromJSON: (json) ->
|
||||
for key,val of json
|
||||
@[key] = val
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
_ = require 'underscore'
|
||||
|
||||
# This keeps track of constructors so we know how to inflate serialized
|
||||
# objects.
|
||||
# Public: This keeps track of constructors so we know how to inflate
|
||||
# serialized objects.
|
||||
#
|
||||
# If 3rd party packages want to register new inflatable models, they can
|
||||
# use `register` and pass the constructor along with the name.
|
||||
|
|
Loading…
Reference in a new issue