mirror of
https://github.com/penpot/penpot.git
synced 2026-09-24 04:46:14 +00:00
✨ Wait previous fail state before retry
This commit is contained in:
parent
fb4d70a82a
commit
e62546a03a
@ -24,7 +24,7 @@
|
|||||||
- Persistence is skipped in version preview/read-only mode or without edit permission.
|
- Persistence is skipped in version preview/read-only mode or without edit permission.
|
||||||
- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable` plus `:invalid-save-response`; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then today's terminal path). Resends rotate the `::request-id` stamp only when the old request left `active-requests`, carry the same `:commit-id`, and never double-send while one request is in flight (`:in-flight` stays silent). Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`.
|
- Save failures split transient vs terminal (`transient-error?`: the repo retryable types `:network`/`:offline`/`:bad-gateway`/`:service-unavailable` plus `:invalid-save-response`; everything else is terminal). Terminal keeps the `:error` halt + `flash-persistence` path; transient enters a `:retrying` episode: the head commit stays queued and resends with backoff 2s/8s/20s (3 retries, then today's terminal path). Resends rotate the `::request-id` stamp only when the old request left `active-requests`, carry the same `:commit-id`, and never double-send while one request is in flight (`:in-flight` stays silent). Retry timers carry the episode token; a superseded token stays silent. Status stays `:retrying` through re-entries (`next-status` refuses `:pending`/`:saving` from it); waiters (`wait-persisted-or-error`) wait through it and reject only on `:error`.
|
||||||
- One reconnect notice per episode: sticky toast tagged `:persistence-reconnecting` (single-toast store, re-show replaces), hidden by tag on drain (`:saved`) and on terminal failure; recovery is silent. Header indicator has a `:retrying` state (`workspace.header.retrying`).
|
- One reconnect notice per episode: sticky toast tagged `:persistence-reconnecting` (single-toast store, re-show replaces), hidden by tag on drain (`:saved`) and on terminal failure; recovery is silent. Header indicator has a `:retrying` state (`workspace.header.retrying`).
|
||||||
- Resume triggers: backoff timer, the browser `online` event (guarded by `exists? js/window`; re-enters the runner only for a live `:retrying` episode), and new local edits (`append-commit` re-enters the runner during `:retrying`; the emission retires the stuck runner via its stopper).
|
- Resume triggers: backoff timer and the browser `online` event (guarded by `exists? js/window`; re-enters the runner only for a live `:retrying` episode). New local edits during `:retrying` only join the queue: the episode keeps its `:run-id`, so `append-commit` does not re-enter, and the live runner (still waiting on the head's `commit-persisted`) sends them after the head. Re-entering on edits would bypass the backoff and spend an attempt per batched commit.
|
||||||
- Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays.
|
- Tests instant-trigger retries by stubbing `rx/timer` (recording delays to assert the schedule); dynamic bindings do not survive `await` continuations, so no dynamic var for delays.
|
||||||
- Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50.
|
- Undo transactions can stay open only temporarily; timed-out pending transactions are force-committed after about 20s. Undo entries are capped at 50.
|
||||||
- Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path.
|
- Undo/redo are ignored while a normal editor/drawing interaction is active, except grid-layout edition handles undo through this path.
|
||||||
|
|||||||
@ -232,14 +232,11 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [pstate (:persistence state)]
|
(let [pstate (:persistence state)]
|
||||||
;; A new edit during `:retrying` re-enters the runner even though
|
;; A `:retrying` episode keeps its run id, so a new edit only
|
||||||
;; the run id no longer matches: the previous runner is stuck
|
;; joins the queue: the live runner sends it after the head, and
|
||||||
;; behind the failed head, and the emission itself retires it
|
;; resends stay on the backoff schedule and its attempt budget.
|
||||||
;; through its stopper. Concurrent re-entries collapse to a
|
|
||||||
;; single send via the in-flight guard in `attempt-state`.
|
|
||||||
(when (and (not= :error (:status pstate))
|
(when (and (not= :error (:status pstate))
|
||||||
(or (= run-id (:run-id pstate))
|
(= run-id (:run-id pstate)))
|
||||||
(= :retrying (:status pstate))))
|
|
||||||
(rx/of (update-status :saving)
|
(rx/of (update-status :saving)
|
||||||
(run-persistence-task))))))))
|
(run-persistence-task))))))))
|
||||||
|
|
||||||
|
|||||||
@ -400,6 +400,12 @@
|
|||||||
(dom/trigger-download-uri "report" "text/plain" uri)
|
(dom/trigger-download-uri "report" "text/plain" uri)
|
||||||
(ts/schedule-on-idle #(wapi/revoke-uri uri))))
|
(ts/schedule-on-idle #(wapi/revoke-uri uri))))
|
||||||
|
|
||||||
|
(defn- report-format
|
||||||
|
"Payload format for `cause`: compact for environment failures, full
|
||||||
|
otherwise."
|
||||||
|
[cause]
|
||||||
|
(if (environment-error? cause) :compact :full))
|
||||||
|
|
||||||
(defn- emit-flash-report!
|
(defn- emit-flash-report!
|
||||||
"Reserves, generates and emits the flash report. Returns the generated
|
"Reserves, generates and emits the flash report. Returns the generated
|
||||||
report string, or nil when nothing is emitted (non-exception cause,
|
report string, or nil when nothing is emitted (non-exception cause,
|
||||||
@ -410,7 +416,7 @@
|
|||||||
:handled "handled-exception"
|
:handled "handled-exception"
|
||||||
:unhandled "unhandled-exception"
|
:unhandled "unhandled-exception"
|
||||||
:silent nil)]
|
:silent nil)]
|
||||||
(let [format (if (environment-error? cause) :compact :full)
|
(let [format (report-format cause)
|
||||||
report-hint (ex/get-hint cause)]
|
report-hint (ex/get-hint cause)]
|
||||||
(when (and (string? report-hint) (not (str/empty? report-hint)))
|
(when (and (string? report-hint) (not (str/empty? report-hint)))
|
||||||
(when-let [{:keys [occurrences]} (reserve! event-name cause)]
|
(when-let [{:keys [occurrences]} (reserve! event-name cause)]
|
||||||
@ -432,6 +438,9 @@
|
|||||||
|
|
||||||
The report is reserved before being generated, so repeated errors that
|
The report is reserved before being generated, so repeated errors that
|
||||||
fall inside the governor window do not pay the report-building cost.
|
fall inside the governor window do not pay the report-building cost.
|
||||||
|
With `:report-link?` the toast always carries a download link: when no
|
||||||
|
report is emitted (suppressed repeat, empty hint, `:silent`), one is
|
||||||
|
generated for the link alone, without emitting it.
|
||||||
|
|
||||||
The whole body (report pipeline first, toast after) runs inside a single
|
The whole body (report pipeline first, toast after) runs inside a single
|
||||||
`ts/schedule` callback: nothing report- or toast-related executes
|
`ts/schedule` callback: nothing report- or toast-related executes
|
||||||
@ -457,19 +466,23 @@
|
|||||||
(fn [resolve _reject]
|
(fn [resolve _reject]
|
||||||
(ts/schedule
|
(ts/schedule
|
||||||
(fn []
|
(fn []
|
||||||
(let [report (try (emit-flash-report! type cause)
|
(let [report (try (emit-flash-report! type cause)
|
||||||
(catch :default err
|
(catch :default err
|
||||||
(.error js/console "error on emitting report" err)
|
(.error js/console "error on emitting report" err)
|
||||||
nil))]
|
nil))
|
||||||
|
link-report (when report-link?
|
||||||
|
(or report
|
||||||
|
(when (ex/exception? cause)
|
||||||
|
(generate-report cause {:format (report-format cause)}))))]
|
||||||
(try (st/emit!
|
(try (st/emit!
|
||||||
(ntf/show
|
(ntf/show
|
||||||
(cond-> {:content (or ^boolean hint (tr "errors.generic"))
|
(cond-> {:content (or ^boolean hint (tr "errors.generic"))
|
||||||
:type :toast
|
:type :toast
|
||||||
:level :error
|
:level :error
|
||||||
:timeout timeout}
|
:timeout timeout}
|
||||||
(and report-link? report)
|
link-report
|
||||||
(assoc :links [{:label (tr "labels.download" "report.txt")
|
(assoc :links [{:label (tr "labels.download" "report.txt")
|
||||||
:callback (partial download-report! report)}]))))
|
:callback (partial download-report! link-report)}]))))
|
||||||
(catch :default err
|
(catch :default err
|
||||||
(.error js/console "error on emitting toast" err)))
|
(.error js/console "error on emitting toast" err)))
|
||||||
(resolve report)))))))
|
(resolve report)))))))
|
||||||
|
|||||||
@ -179,9 +179,9 @@
|
|||||||
(fn [_ _] (rx/throw (ex-info "offline" {:type :offline})))))))
|
(fn [_ _] (rx/throw (ex-info "offline" {:type :offline})))))))
|
||||||
|
|
||||||
;; Scenario: a retry timer fires while the replacement request is still in
|
;; Scenario: a retry timer fires while the replacement request is still in
|
||||||
;; flight. The timer is driven by hand: fail the first send, queue a second
|
;; flight. The timer is driven by hand: fail the first send, let the
|
||||||
;; edit so its run resends (hanging), then fire the pending timer. Proves:
|
;; online signal resend the head (hanging), then fire the pending timer.
|
||||||
;; the firing is skipped instead of double-sending the same commit.
|
;; Proves: the firing is skipped instead of double-sending the same commit.
|
||||||
(t/deftest ^:async retry-skips-resend-while-previous-request-is-in-flight
|
(t/deftest ^:async retry-skips-resend-while-previous-request-is-in-flight
|
||||||
(let [calls (atom 0)
|
(let [calls (atom 0)
|
||||||
delays (atom [])
|
delays (atom [])
|
||||||
@ -198,11 +198,10 @@
|
|||||||
(await (async/wait-for #(= :retrying (get-in @store [:persistence :status]))
|
(await (async/wait-for #(= :retrying (get-in @store [:persistence :status]))
|
||||||
"transient failure retries"))
|
"transient failure retries"))
|
||||||
(t/is (= 1 (count @requests)))
|
(t/is (= 1 (count @requests)))
|
||||||
;; Phase 2 — a new edit re-enters the runner under the live
|
;; Phase 2 — the online signal resends the head under the live
|
||||||
;; episode (status stays :retrying) and resends the head,
|
;; episode (status stays :retrying), hanging in flight.
|
||||||
;; hanging in flight.
|
(ptk/emit! store (#'dps/resume-on-online))
|
||||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
(await (async/wait-for #(= 2 (count @requests)) "online resends"))
|
||||||
(await (async/wait-for #(= 2 (count @requests)) "second edit resends"))
|
|
||||||
(t/is (= :retrying (get-in @store [:persistence :status])))
|
(t/is (= :retrying (get-in @store [:persistence :status])))
|
||||||
(t/is (= 1 (get-in @store [:persistence :attempts])))
|
(t/is (= 1 (get-in @store [:persistence :attempts])))
|
||||||
;; Phase 3 — the pending timer fires into the in-flight request:
|
;; Phase 3 — the pending timer fires into the in-flight request:
|
||||||
@ -211,7 +210,7 @@
|
|||||||
(await (async/settle))
|
(await (async/settle))
|
||||||
(t/is (= 2 (count @requests)) "no double-send while in flight")
|
(t/is (= 2 (count @requests)) "no double-send while in flight")
|
||||||
(t/is (= :retrying (get-in @store [:persistence :status])))
|
(t/is (= :retrying (get-in @store [:persistence :status])))
|
||||||
(t/is (= 2 (count (get-in @store [:persistence :queue]))))
|
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
|
||||||
(rx/end! timer-s))))
|
(rx/end! timer-s))))
|
||||||
(fn [_ _]
|
(fn [_ _]
|
||||||
(if (= 1 (swap! calls inc))
|
(if (= 1 (swap! calls inc))
|
||||||
@ -242,6 +241,51 @@
|
|||||||
(t/is (nil? (get-in @store [:persistence :error]))))))
|
(t/is (nil? (get-in @store [:persistence :error]))))))
|
||||||
(fn [_ _] (rx/throw (ex-info "offline" {:type :offline}))))))
|
(fn [_ _] (rx/throw (ex-info "offline" {:type :offline}))))))
|
||||||
|
|
||||||
|
;; Scenario: a new edit lands while the episode waits on its backoff timer.
|
||||||
|
;; The edit only joins the queue: nothing is sent and no attempt is spent
|
||||||
|
;; until the timer fires. The retry then saves the head and the runner
|
||||||
|
;; sends the new edit after it. Proves: edits during an episode never
|
||||||
|
;; bypass the backoff nor consume the retry budget.
|
||||||
|
(t/deftest ^:async new-edit-during-retry-waits-for-the-backoff
|
||||||
|
(let [calls (atom 0)
|
||||||
|
delays (atom [])
|
||||||
|
timer-s (rx/subject)]
|
||||||
|
(await
|
||||||
|
(with-persistence
|
||||||
|
(^:async fn [{:keys [file-id requests store]}]
|
||||||
|
(await
|
||||||
|
(mock/with-mocks*
|
||||||
|
{rx/timer (mock/stub (fn [ms] (swap! delays conj ms) timer-s))}
|
||||||
|
;; Phase 1 — first send fails transiently; the retry pends on
|
||||||
|
;; the hand-fired timer.
|
||||||
|
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||||
|
(await (async/wait-for #(= :retrying (get-in @store [:persistence :status]))
|
||||||
|
"transient failure retries"))
|
||||||
|
(t/is (= 1 (count @requests)))
|
||||||
|
;; Phase 2 — a new edit joins the queue without resending.
|
||||||
|
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
||||||
|
(await (async/wait-for #(= 2 (count (get-in @store [:persistence :queue])))
|
||||||
|
"second edit is queued"))
|
||||||
|
(await (async/settle))
|
||||||
|
(t/is (= 1 (count @requests)) "the edit does not resend the head")
|
||||||
|
(t/is (= 1 (get-in @store [:persistence :attempts])) "no attempt is spent")
|
||||||
|
(t/is (= [2000] @delays) "the pending backoff is kept")
|
||||||
|
(t/is (= :retrying (get-in @store [:persistence :status])))
|
||||||
|
;; Phase 3 — the timer fires: the head saves, then the new edit.
|
||||||
|
(rx/push! timer-s :tick)
|
||||||
|
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
|
||||||
|
(empty? (get-in @store [:persistence :queue])))
|
||||||
|
"both edits save"))
|
||||||
|
(t/is (= 3 (count @requests)) "failed send, retry, then the new edit")
|
||||||
|
(let [[first-id retry-id edit-id] (map (comp :commit-id second) @requests)]
|
||||||
|
(t/is (= first-id retry-id) "the retry resends the head")
|
||||||
|
(t/is (not= retry-id edit-id) "the new edit is sent after the head"))
|
||||||
|
(rx/end! timer-s))))
|
||||||
|
(fn [_ _]
|
||||||
|
(if (= 1 (swap! calls inc))
|
||||||
|
(rx/throw (ex-info "offline" {:type :offline}))
|
||||||
|
(rx/of {:revn @calls})))))))
|
||||||
|
|
||||||
;; Scenario: a transient failure raises the reconnect notice; the retry
|
;; Scenario: a transient failure raises the reconnect notice; the retry
|
||||||
;; then succeeds. The timer is driven by hand so the test observes the
|
;; then succeeds. The timer is driven by hand so the test observes the
|
||||||
;; episode mid-flight: one visible notice (the store holds a single toast,
|
;; episode mid-flight: one visible notice (the store holds a single toast,
|
||||||
@ -342,11 +386,11 @@
|
|||||||
(filter #(= ::ev/event (ptk/type %)) events))
|
(filter #(= ::ev/event (ptk/type %)) events))
|
||||||
|
|
||||||
;; Scenario: the `online` event arrives while a retry request is already in
|
;; Scenario: the `online` event arrives while a retry request is already in
|
||||||
;; flight. The first send fails transiently, a new edit re-enters and
|
;; flight. The first send fails transiently, the retry timer resends
|
||||||
;; resends (hanging), then connectivity reports back mid-flight. Proves:
|
;; (hanging), then connectivity reports back mid-flight. Proves: the online
|
||||||
;; the online entry point honors the in-flight guard instead of
|
;; entry point honors the in-flight guard instead of double-sending — the
|
||||||
;; double-sending — the same guard as the retry-timer path, through
|
;; same guard as the retry-timer path, through `resume-on-online` ->
|
||||||
;; `resume-on-online` -> `run-persistence-task`.
|
;; `run-persistence-task`.
|
||||||
(t/deftest ^:async online-event-does-not-resend-an-in-flight-request
|
(t/deftest ^:async online-event-does-not-resend-an-in-flight-request
|
||||||
(let [calls (atom 0)
|
(let [calls (atom 0)
|
||||||
timer-s (rx/subject)]
|
timer-s (rx/subject)]
|
||||||
@ -362,10 +406,10 @@
|
|||||||
(await (async/wait-for #(= :retrying (get-in @store [:persistence :status]))
|
(await (async/wait-for #(= :retrying (get-in @store [:persistence :status]))
|
||||||
"transient failure retries"))
|
"transient failure retries"))
|
||||||
(t/is (= 1 (count @requests)))
|
(t/is (= 1 (count @requests)))
|
||||||
;; Phase 2 — a new edit re-enters the runner under the live
|
;; Phase 2 — the retry timer fires and resends the head,
|
||||||
;; episode and resends the head, hanging in flight.
|
;; hanging in flight.
|
||||||
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
|
(rx/push! timer-s :tick)
|
||||||
(await (async/wait-for #(= 2 (count @requests)) "second edit resends"))
|
(await (async/wait-for #(= 2 (count @requests)) "retry resends"))
|
||||||
(t/is (= :retrying (get-in @store [:persistence :status])))
|
(t/is (= :retrying (get-in @store [:persistence :status])))
|
||||||
;; Phase 3 — online arrives mid-flight: silent, never a third send.
|
;; Phase 3 — online arrives mid-flight: silent, never a third send.
|
||||||
(ptk/emit! store (#'dps/resume-on-online))
|
(ptk/emit! store (#'dps/resume-on-online))
|
||||||
|
|||||||
@ -357,3 +357,47 @@
|
|||||||
tm/schedule (mock/stub (fn [f] (mock/asap f)))}
|
tm/schedule (mock/stub (fn [f] (mock/asap f)))}
|
||||||
(await (errors/flash :cause cause :type :handled))
|
(await (errors/flash :cause cause :type :handled))
|
||||||
(t/is (= 1 (count @events)) "only the toast survives a reporting failure")))))
|
(t/is (= 1 (count @events)) "only the toast survives a reporting failure")))))
|
||||||
|
|
||||||
|
(defn- toast-links
|
||||||
|
"The download links of every toast collected through the `st/emit!`
|
||||||
|
double, one entry per toast."
|
||||||
|
[events]
|
||||||
|
(->> events
|
||||||
|
(remove #(= ::ev/event (ptk/type %)))
|
||||||
|
(map #(get-in (ptk/update % {}) [:notification :links]))))
|
||||||
|
|
||||||
|
(t/deftest ^:async repeated-flash-keeps-the-report-download-link
|
||||||
|
;; Scenario: the same save failure flashes twice inside the governor
|
||||||
|
;; window with a report link. The repeat is not emitted, but its toast
|
||||||
|
;; still offers the report. Proves: the governor bounds emission, never
|
||||||
|
;; the user's download.
|
||||||
|
(let [generated (atom 0)
|
||||||
|
cause (error-cause :type :validation :hint "save failed")
|
||||||
|
events (atom [])]
|
||||||
|
(await
|
||||||
|
(mock/with-mocks*
|
||||||
|
{st/format-last-events (mock/stub (fn [& _] (swap! generated inc) "report"))
|
||||||
|
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||||||
|
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||||||
|
tm/schedule (mock/stub (fn [f] (mock/asap f)))}
|
||||||
|
(dotimes [_ 2]
|
||||||
|
(await (errors/flash :cause cause :type :handled :report-link? true)))
|
||||||
|
(t/is (= 1 (count (report-events @events))) "the repeat is not emitted")
|
||||||
|
(t/is (= [1 1] (map count (toast-links @events))) "both toasts carry the link")
|
||||||
|
(t/is (= 2 @generated) "the repeat builds its report for the link only")))))
|
||||||
|
|
||||||
|
(t/deftest ^:async unreportable-flash-keeps-the-report-download-link
|
||||||
|
;; Scenario: a failure with an empty hint flashes with a report link. It
|
||||||
|
;; cannot be emitted, but its toast still offers the report. Proves: the
|
||||||
|
;; link does not depend on emission.
|
||||||
|
(let [cause (ex-info "" {:type :validation})
|
||||||
|
events (atom [])]
|
||||||
|
(await
|
||||||
|
(mock/with-mocks*
|
||||||
|
{st/format-last-events (mock/stub (fn [& _] "report"))
|
||||||
|
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||||||
|
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
|
||||||
|
tm/schedule (mock/stub (fn [f] (mock/asap f)))}
|
||||||
|
(await (errors/flash :cause cause :type :handled :report-link? true))
|
||||||
|
(t/is (empty? (report-events @events)) "nothing is emitted")
|
||||||
|
(t/is (= [1] (map count (toast-links @events))) "the toast carries the link")))))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user