diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 5fc6a7d43c5..3cc1bb05d91 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -21,6 +21,9 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Changed - Small messages during annotating (e.g. “finished undo”, “applying mapping…”) are now click-through so they do not block users from selecting tools. [7239](https://github.com/scalableminds/webknossos/pull/7239) +- Annotating volume data uses a transaction-based mechanism now. As a result, WK is more robust against partial saves (i.e., due to a crashing tab). [#7264](https://github.com/scalableminds/webknossos/pull/7264) +- Improved speed of saving volume data. [#7264](https://github.com/scalableminds/webknossos/pull/7264) +- Improved progress indicator when saving volume data. [#7264](https://github.com/scalableminds/webknossos/pull/7264) - The order of color layers can now also be manipulated in additive blend mode (see [#7188](https://github.com/scalableminds/webknossos/pull/7188)). [#7289](https://github.com/scalableminds/webknossos/pull/7289) - OpenID Connect authorization now fetches the server’s public key automatically. The config keys `singleSignOn.openIdConnect.publicKey` and `singleSignOn.openIdConnect.publicKeyAlgorithm` are now unused. [7267](https://github.com/scalableminds/webknossos/pull/7267) diff --git a/frontend/javascripts/libs/async/async_fifo_resolver.ts b/frontend/javascripts/libs/async/async_fifo_resolver.ts new file mode 100644 index 00000000000..f0e0e52d2cc --- /dev/null +++ b/frontend/javascripts/libs/async/async_fifo_resolver.ts @@ -0,0 +1,35 @@ +/* + * This class can be used to await promises + * in the order they were passed to + * orderedWaitFor. + * + * This enables scheduling of asynchronous work + * concurrently while ensuring that the results + * are processed in the order they were requested + * (instead of the order in which they finished). + * + * Example: + * const resolver = new AsyncFifoResolver(); + * const promise1Done = resolver.orderedWaitFor(promise1); + * const promise2Done = resolver.orderedWaitFor(promise2); + * + * Even if promise2 resolves before promise1, promise2Done + * will resolve *after* promise1Done. + */ + +export class AsyncFifoResolver { + queue: Promise[]; + constructor() { + this.queue = []; + } + + async orderedWaitFor(promise: Promise): Promise { + this.queue.push(promise); + const promiseCountToAwait = this.queue.length; + const retVals = await Promise.all(this.queue); + // Note that this.queue can have changed during the await. + // Find the index of the promise and trim the queue accordingly. + this.queue = this.queue.slice(this.queue.indexOf(promise) + 1); + return retVals[promiseCountToAwait - 1]; + } +} diff --git a/frontend/javascripts/libs/async/debounced_abortable_saga.ts b/frontend/javascripts/libs/async/debounced_abortable_saga.ts new file mode 100644 index 00000000000..63feaa1f8ba --- /dev/null +++ b/frontend/javascripts/libs/async/debounced_abortable_saga.ts @@ -0,0 +1,95 @@ +import { call, type Saga } from "oxalis/model/sagas/effect-generators"; +import { buffers, Channel, channel, runSaga } from "redux-saga"; +import { delay, race, take } from "redux-saga/effects"; + +/* + * This function takes a saga and a debounce threshold + * and returns a function F that will trigger the given saga + * in a debounced manner. + * In contrast to a normal debouncing mechanism, the saga + * will be cancelled if F is called while the saga is running. + * Note that this means that concurrent executions of the saga + * are impossible that way (by design). + * + * Also note that the performance of this debouncing mechanism + * is slower than a standard _.debounce. Also see + * debounced_abortable_saga.spec.ts for a small benchmark. + */ +export function createDebouncedAbortableCallable( + fn: (param1: T) => Saga, + debounceThreshold: number, + context: C, +) { + // The communication with the saga is done via a channel. + // That way, we can expose a normal function that + // does the triggering by filling the channel. + + // Only the most recent invocation should survive. + // Therefore, create a sliding buffer with size 1. + const buffer = buffers.sliding(1); + const triggerChannel: Channel = channel(buffer); + + const _task = runSaga( + {}, + debouncedAbortableSagaRunner, + debounceThreshold, + triggerChannel, + // @ts-expect-error TS thinks fn doesnt match, but it does. + fn, + context, + ); + + return (msg: T) => { + triggerChannel.put(msg); + }; +} + +export function createDebouncedAbortableParameterlessCallable( + fn: () => Saga, + debounceThreshold: number, + context: C, +) { + const wrappedFn = createDebouncedAbortableCallable(fn, debounceThreshold, context); + const dummyParameter = {}; + return () => { + wrappedFn(dummyParameter); + }; +} + +function* debouncedAbortableSagaRunner( + debounceThreshold: number, + triggerChannel: Channel, + abortableFn: (param: T) => Saga, + context: C, +): Saga { + while (true) { + // Wait for a trigger-call by consuming + // the channel. + let msg = yield take(triggerChannel); + + // Repeatedly try to execute abortableFn (each try + // might be cancelled due to new initiation-requests) + while (true) { + const { debounced, latestMessage } = yield race({ + debounced: delay(debounceThreshold), + latestMessage: take(triggerChannel), + }); + + if (latestMessage) { + msg = latestMessage; + } + + if (debounced) { + const { abortingMessage } = yield race({ + finished: call([context, abortableFn], msg), + abortingMessage: take(triggerChannel), + }); + if (abortingMessage) { + msg = abortingMessage; + } else { + break; + } + } + } + } +} diff --git a/frontend/javascripts/libs/deferred.ts b/frontend/javascripts/libs/async/deferred.ts similarity index 100% rename from frontend/javascripts/libs/deferred.ts rename to frontend/javascripts/libs/async/deferred.ts diff --git a/frontend/javascripts/libs/latest_task_executor.ts b/frontend/javascripts/libs/async/latest_task_executor.ts similarity index 92% rename from frontend/javascripts/libs/latest_task_executor.ts rename to frontend/javascripts/libs/async/latest_task_executor.ts index dd660cf9672..d9c7c668b87 100644 --- a/frontend/javascripts/libs/latest_task_executor.ts +++ b/frontend/javascripts/libs/async/latest_task_executor.ts @@ -1,4 +1,4 @@ -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; type Task = () => Promise; export const SKIPPED_TASK_REASON = "Skipped task"; /* @@ -11,6 +11,10 @@ export const SKIPPED_TASK_REASON = "Skipped task"; * LatestTaskExecutor instance. * * See the corresponding spec for examples. + * + * If you need the same behavior plus cancellation of running + * tasks, take a look at the saga-based `createDebouncedAbortableCallable` + * utility. */ export default class LatestTaskExecutor { diff --git a/frontend/javascripts/libs/task_pool.ts b/frontend/javascripts/libs/async/task_pool.ts similarity index 73% rename from frontend/javascripts/libs/task_pool.ts rename to frontend/javascripts/libs/async/task_pool.ts index ce2a8947b50..c188092dc5b 100644 --- a/frontend/javascripts/libs/task_pool.ts +++ b/frontend/javascripts/libs/async/task_pool.ts @@ -1,5 +1,5 @@ -import type { Saga, Task } from "oxalis/model/sagas/effect-generators"; -import { join, call, fork } from "typed-redux-saga"; +import type { Saga } from "oxalis/model/sagas/effect-generators"; +import { join, call, fork, FixedTask } from "typed-redux-saga"; /* Given an array of async tasks, processTaskWithPool @@ -10,12 +10,11 @@ export default function* processTaskWithPool( tasks: Array<() => Saga>, poolSize: number, ): Saga { - const startedTasks: Array> = []; + const startedTasks: Array> = []; let isFinalResolveScheduled = false; let error: Error | null = null; - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'fn' implicitly has an 'any' type. - function* forkSafely(fn): Saga { + function* forkSafely(fn: () => Saga): Saga { // Errors from forked tasks cannot be caught, see https://redux-saga.js.org/docs/advanced/ForkModel/#error-propagation // However, the task pool should not abort if a single task fails. // Therefore, use this wrapper to safely execute all tasks and possibly rethrow the last error in the end. @@ -32,7 +31,6 @@ export default function* processTaskWithPool( isFinalResolveScheduled = true; // All tasks were kicked off, which is why all tasks can be // awaited now together. - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. yield* join(startedTasks); if (error != null) throw error; } @@ -40,9 +38,8 @@ export default function* processTaskWithPool( return; } - const task = tasks.shift(); + const task = tasks.shift() as () => Saga; const newTask = yield* fork(forkSafely, task); - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'FixedTask' is not assignab... Remove this comment to see the full error message startedTasks.push(newTask); // If that task is done, process a new one (that way, // the pool size stays constant until the queue is almost empty.) diff --git a/frontend/javascripts/libs/async_task_queue.ts b/frontend/javascripts/libs/async_task_queue.ts deleted file mode 100644 index 204956d1d6f..00000000000 --- a/frontend/javascripts/libs/async_task_queue.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable no-await-in-loop */ -import { createNanoEvents, Emitter } from "nanoevents"; -import _ from "lodash"; -import Deferred from "libs/deferred"; -import * as Utils from "libs/utils"; -type AsyncTask = () => Promise; - -class AsyncTaskQueue { - // Executes asynchronous tasks in order. - // - // Each action is executed after the previous action - // is finished. Any output of the previous action is - // passed to the current action. - maxRetry: number; - retryTimeMs: number; - failureEventThreshold: number; - tasks: Array = []; - deferreds: Map> = new Map(); - doneDeferred: Deferred = new Deferred(); - retryCount: number = 0; - running: boolean = false; - failed: boolean = false; - emitter: Emitter; - - constructor(maxRetry: number = 3, retryTimeMs: number = 1000, failureEventThreshold: number = 3) { - this.emitter = createNanoEvents(); - - this.maxRetry = maxRetry; - this.retryTimeMs = retryTimeMs; - this.failureEventThreshold = failureEventThreshold; - } - - isBusy(): boolean { - return this.running || this.tasks.length !== 0; - } - - scheduleTask(task: AsyncTask): Promise { - this.tasks.push(task); - const deferred = new Deferred(); - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Deferred' is n... Remove this comment to see the full error message - this.deferreds.set(task, deferred); - - if (this.failed) { - this.restart(); - } - - if (!this.running) { - this.executeNext(); - } - - // @ts-expect-error ts-migrate(2322) FIXME: Type 'Promise' is not assignable to type ... Remove this comment to see the full error message - return deferred.promise(); - } - - scheduleTasks(tasks: Array): Promise { - return Promise.all(tasks.map((task) => this.scheduleTask(task))); - } - - async restart(): Promise { - // To restart the pipeline after it failed. - // Returns a new Promise for the first item. - if (this.failed && this.tasks.length > 0) { - this.failed = false; - this.retryCount = 0; - this.running = false; - // Reinsert first action - await this.executeNext(); - } - } - - signalResolve(task: AsyncTask, obj: any): void { - const deferred = this.deferreds.get(task); - this.deferreds.delete(task); - - if (deferred != null) { - deferred.resolve(obj); - } - } - - signalReject(task: AsyncTask, error: any): void { - const deferred = this.deferreds.get(task); - this.deferreds.delete(task); - - if (deferred != null) { - deferred.reject(error); - } - } - - join(): Promise { - if (this.isBusy()) { - return this.doneDeferred.promise(); - } else { - return Promise.resolve(); - } - } - - async executeNext(): Promise { - this.running = true; - - while (this.tasks.length > 0) { - const currentTask = this.tasks.shift(); - - try { - // @ts-expect-error ts-migrate(2722) FIXME: Cannot invoke an object which is possibly 'undefin... Remove this comment to see the full error message - const response = await currentTask(); - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'AsyncTask | undefined' is not as... Remove this comment to see the full error message - this.signalResolve(currentTask, response); - this.emitter.emit("success"); - } catch (error) { - this.retryCount++; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'AsyncTask | undefined' is not as... Remove this comment to see the full error message - this.tasks.unshift(currentTask); - - if (this.retryCount > this.failureEventThreshold) { - console.error("AsyncTaskQueue failed with error", error); - this.emitter.emit("failure", this.retryCount); - } - - if (this.retryCount >= this.maxRetry) { - this.failed = true; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'AsyncTask | undefined' is not as... Remove this comment to see the full error message - this.signalReject(currentTask, error); - this.running = false; - this.doneDeferred.reject(error); - this.doneDeferred = new Deferred(); - return; - } else { - await Utils.sleep(this.retryTimeMs); - } - } - } - - this.running = false; - this.doneDeferred.resolve(); - this.doneDeferred = new Deferred(); - } - - on(event: string | number, cb: (...args: any) => void) { - this.emitter.on(event, cb); - } -} - -export default AsyncTaskQueue; diff --git a/frontend/javascripts/libs/worker_pool.ts b/frontend/javascripts/libs/webworker_pool.ts similarity index 83% rename from frontend/javascripts/libs/worker_pool.ts rename to frontend/javascripts/libs/webworker_pool.ts index 7a07172aa7f..5803e6c3354 100644 --- a/frontend/javascripts/libs/worker_pool.ts +++ b/frontend/javascripts/libs/webworker_pool.ts @@ -1,11 +1,11 @@ import _ from "lodash"; -export default class WorkerPool { +export default class WebWorkerPool { // This class can be used to instantiate multiple web workers // which are then used for computation in a simple round-robin manner. // // Example: - // const compressionPool = new WorkerPool( - // () => createWorker(ByteArrayToLz4Base64Worker), + // const compressionPool = new WebWorkerPool( + // () => createWorker(ByteArraysToLz4Base64Worker), // COMPRESSION_WORKER_COUNT, // ); // const promise1 = compressionPool.submit(data1); diff --git a/frontend/javascripts/oxalis/model.ts b/frontend/javascripts/oxalis/model.ts index a6e4ba7302a..eb80727e193 100644 --- a/frontend/javascripts/oxalis/model.ts +++ b/frontend/javascripts/oxalis/model.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import { COMPRESSING_BATCH_SIZE } from "oxalis/model/bucket_data_handling/pushqueue"; import type { Vector3 } from "oxalis/constants"; import type { Versions } from "oxalis/view/version_view"; import { getActiveSegmentationTracingLayer } from "oxalis/model/accessors/volumetracing_accessor"; @@ -286,21 +285,35 @@ export class OxalisModel { return storeStateSaved && pushQueuesSaved; } + getLongestPushQueueWaitTime() { + return ( + _.max( + Utils.values(this.dataLayers).map((layer) => layer.pushQueue.getTransactionWaitTime()), + ) || 0 + ); + } + getPushQueueStats() { const compressingBucketCount = _.sum( - Utils.values(this.dataLayers).map( - (dataLayer) => - dataLayer.pushQueue.compressionTaskQueue.tasks.length * COMPRESSING_BATCH_SIZE, + Utils.values(this.dataLayers).map((dataLayer) => + dataLayer.pushQueue.getCompressingBucketCount(), ), ); const waitingForCompressionBucketCount = _.sum( - Utils.values(this.dataLayers).map((dataLayer) => dataLayer.pushQueue.pendingQueue.size), + Utils.values(this.dataLayers).map((dataLayer) => dataLayer.pushQueue.getPendingBucketCount()), + ); + + const outstandingBucketDownloadCount = _.sum( + Utils.values(this.dataLayers).map((dataLayer) => + dataLayer.cube.temporalBucketManager.getCount(), + ), ); return { compressingBucketCount, waitingForCompressionBucketCount, + outstandingBucketDownloadCount, }; } diff --git a/frontend/javascripts/oxalis/model/actions/actions.ts b/frontend/javascripts/oxalis/model/actions/actions.ts index a6bef847adc..7fcea4d403c 100644 --- a/frontend/javascripts/oxalis/model/actions/actions.ts +++ b/frontend/javascripts/oxalis/model/actions/actions.ts @@ -14,6 +14,8 @@ import type { ConnectomeAction } from "oxalis/model/actions/connectome_actions"; import { ProofreadAction } from "oxalis/model/actions/proofread_actions"; import { OrganizationAction } from "oxalis/model/actions/organization_actions"; +export type EscalateErrorAction = ReturnType; + export type Action = | SkeletonTracingAction | VolumeTracingAction @@ -32,7 +34,8 @@ export type Action = | OrganizationAction | ReturnType | ReturnType - | ReturnType; + | ReturnType + | EscalateErrorAction; export const wkReadyAction = () => ({ @@ -48,3 +51,9 @@ export const restartSagaAction = () => ({ type: "RESTART_SAGA", } as const); + +export const escalateErrorAction = (error: unknown) => + ({ + type: "ESCALATE_ERROR", + error, + } as const); diff --git a/frontend/javascripts/oxalis/model/actions/annotation_actions.ts b/frontend/javascripts/oxalis/model/actions/annotation_actions.ts index 97d6970948a..3756d3fcdc0 100644 --- a/frontend/javascripts/oxalis/model/actions/annotation_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/annotation_actions.ts @@ -16,7 +16,7 @@ import type { import type { Vector3 } from "oxalis/constants"; import _ from "lodash"; import { Dispatch } from "redux"; -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; type InitializeAnnotationAction = ReturnType; type SetAnnotationNameAction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/actions/save_actions.ts b/frontend/javascripts/oxalis/model/actions/save_actions.ts index 650a14eea18..d732d22b130 100644 --- a/frontend/javascripts/oxalis/model/actions/save_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/save_actions.ts @@ -2,7 +2,7 @@ import type { Dispatch } from "redux"; import type { UpdateAction } from "oxalis/model/sagas/update_actions"; import { getUid } from "libs/uid_generator"; import Date from "libs/date"; -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; export type SaveQueueType = "skeleton" | "volume" | "mapping"; export type PushSaveQueueTransaction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/actions/settings_actions.ts b/frontend/javascripts/oxalis/model/actions/settings_actions.ts index 06723c1288f..24b85b1e342 100644 --- a/frontend/javascripts/oxalis/model/actions/settings_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/settings_actions.ts @@ -7,7 +7,7 @@ import type { Mapping, MappingType, } from "oxalis/store"; -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; import { APIHistogramData } from "types/api_flow_types"; export type UpdateUserSettingAction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index cc5b9e5055d..2acc1d7bab9 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -2,7 +2,7 @@ import type { ServerEditableMapping, ServerVolumeTracing } from "types/api_flow_ import type { Vector2, Vector3, Vector4, OrthoView, ContourMode } from "oxalis/constants"; import type { BucketDataArray } from "oxalis/model/bucket_data_handling/bucket"; import type { Segment, SegmentGroup, SegmentMap } from "oxalis/store"; -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; import type { Dispatch } from "redux"; import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; import { QuickSelectGeometry } from "oxalis/geometries/helper_geometries"; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts index 5fa24d16836..89102666e21 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts @@ -16,7 +16,7 @@ import { } from "oxalis/model/accessors/dataset_accessor"; import AsyncBucketPickerWorker from "oxalis/workers/async_bucket_picker.worker"; import type DataCube from "oxalis/model/bucket_data_handling/data_cube"; -import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/latest_task_executor"; +import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/async/latest_task_executor"; import type PullQueue from "oxalis/model/bucket_data_handling/pullqueue"; import Store, { PlaneRects, SegmentMap } from "oxalis/store"; import TextureBucketManager from "oxalis/model/bucket_data_handling/texture_bucket_manager"; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/pushqueue.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/pushqueue.ts index fe0fbf64e30..549d4293858 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/pushqueue.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/pushqueue.ts @@ -1,118 +1,165 @@ import _ from "lodash"; import type { DataBucket } from "oxalis/model/bucket_data_handling/bucket"; -import { alert, document } from "libs/window"; -import { sendToStore } from "oxalis/model/bucket_data_handling/wkstore_adapter"; -import AsyncTaskQueue from "libs/async_task_queue"; +import { createCompressedUpdateBucketActions } from "oxalis/model/bucket_data_handling/wkstore_adapter"; import type DataCube from "oxalis/model/bucket_data_handling/data_cube"; -import Toast from "libs/toast"; -export const COMPRESSING_BATCH_SIZE = 32; -// Only process the PushQueue after there was no user interaction -// for PUSH_DEBOUNCE_TIME milliseconds... +import { createDebouncedAbortableParameterlessCallable } from "libs/async/debounced_abortable_saga"; +import { call } from "redux-saga/effects"; +import Store from "oxalis/store"; +import { pushSaveQueueTransaction } from "../actions/save_actions"; +import { UpdateAction } from "../sagas/update_actions"; +import { AsyncFifoResolver } from "libs/async/async_fifo_resolver"; +import { escalateErrorAction } from "../actions/actions"; + +// Only process the PushQueue after there was no user interaction (or bucket modification due to +// downsampling) for PUSH_DEBOUNCE_TIME milliseconds. +// PushQueue.getTransactionWaitTime is used to avoid debounce-related starvation that can happen +// when the user constantly annotates. const PUSH_DEBOUNCE_TIME = 1000; -// ...unless a timeout of PUSH_DEBOUNCE_MAX_WAIT_TIME milliseconds -// is exceeded. Then, initiate a push. -const PUSH_DEBOUNCE_MAX_WAIT_TIME = 30000; class PushQueue { - // @ts-expect-error ts-migrate(2564) FIXME: Property 'dataSetName' has no initializer and is n... Remove this comment to see the full error message - dataSetName: string; cube: DataCube; - compressionTaskQueue: AsyncTaskQueue; - sendData: boolean; - // The pendingQueue contains all buckets which are marked as - // "should be snapshotted and saved". That queue is processed - // in a debounced manner and sent to the `compressionTaskQueue`. - // The `compressionTaskQueue` compresses the bucket data and - // sends it to the save queue. - pendingQueue: Set; - - constructor(cube: DataCube, sendData: boolean = true) { + + // The pendingBuckets contains all buckets that should be: + // - snapshotted, + // - put into one transaction and then + // - saved + // That set is flushed in a debounced manner so that the time of the + // snapshot should be suitable for a transaction (since neither WK nor the + // user edited the buckets in a certain time window). + private pendingBuckets: Set; + + // Everytime the pendingBuckets is flushed, its content is put into a transaction. + // That transaction is compressed asynchronously before it is sent to the store. + // Buckets that are currently being compressed, are counted in this property. + private compressingBucketCount: number = 0; + + // Helper to ensure the Store's save queue is filled in the correct + // order. + private fifoResolver = new AsyncFifoResolver(); + + // If the timestamp is defined, it encodes when the first bucket + // was added to the PushQueue that will be part of the next (to be created) + // transaction. + private waitTimeStartTimeStamp: number | null = null; + + constructor(cube: DataCube) { this.cube = cube; - this.compressionTaskQueue = new AsyncTaskQueue(Infinity); - this.sendData = sendData; - this.pendingQueue = new Set(); - const autoSaveFailureMessage = "Auto-Save failed!"; - this.compressionTaskQueue.on("failure", () => { - console.error("PushQueue failure"); - - if (document.body != null) { - document.body.classList.add("save-error"); - } - - Toast.error(autoSaveFailureMessage, { - sticky: true, - }); - }); - this.compressionTaskQueue.on("success", () => { - if (document.body != null) { - document.body.classList.remove("save-error"); - } - - Toast.close(autoSaveFailureMessage); - }); + this.pendingBuckets = new Set(); } stateSaved(): boolean { return ( - this.pendingQueue.size === 0 && + this.pendingBuckets.size === 0 && this.cube.temporalBucketManager.getCount() === 0 && - !this.compressionTaskQueue.isBusy() + this.compressingBucketCount === 0 ); } insert(bucket: DataBucket): void { - if (!this.pendingQueue.has(bucket)) { - this.pendingQueue.add(bucket); + if (this.waitTimeStartTimeStamp == null) { + this.waitTimeStartTimeStamp = Date.now(); + } + if (!this.pendingBuckets.has(bucket)) { + this.pendingBuckets.add(bucket); bucket.dirtyCount++; } - this.push(); } - clear(): void { - this.pendingQueue.clear(); + getPendingBucketCount(): number { + return this.pendingBuckets.size; } - print(): void { - this.pendingQueue.forEach((e) => console.log(e)); + getCompressingBucketCount(): number { + return this.compressingBucketCount; } - pushImpl = async () => { - await this.cube.temporalBucketManager.getAllLoadedPromise(); - - if (!this.sendData) { - return; + getTransactionWaitTime(): number { + // Return how long we are waiting for the transaction flush + // (waiting time depends on the user activity and on the + // time it takes to download buckets). + if (this.waitTimeStartTimeStamp == null) { + // No pending buckets exist. There's no wait time. + return 0; } - while (this.pendingQueue.size) { - let batchSize = Math.min(COMPRESSING_BATCH_SIZE, this.pendingQueue.size); - const batch: DataBucket[] = []; + return Date.now() - this.waitTimeStartTimeStamp; + } - for (const bucket of this.pendingQueue) { - if (batchSize <= 0) break; - this.pendingQueue.delete(bucket); - batch.push(bucket); - batchSize--; - } + clear(): void { + this.pendingBuckets.clear(); + } - // fire and forget - this.compressionTaskQueue.scheduleTask(() => this.pushBatch(batch)); - } + print(): void { + this.pendingBuckets.forEach((e) => console.log(e)); + } + pushImpl = function* (this: PushQueue) { try { - // wait here - await this.compressionTaskQueue.join(); - } catch (_error) { - alert("We've encountered a permanent issue while saving. Please try to reload the page."); + // Wait until there are no temporal buckets, anymore, so that + // all buckets can be snapshotted and saved to the server. + // If PushQueue.push() is called while we are waiting here, + // this generator is aborted and the debounce-time begins + // again. + yield call(this.cube.temporalBucketManager.getAllLoadedPromise); + + // It is important that flushAndSnapshot does not use a generator + // mechanism, because it could get cancelled due to + // createDebouncedAbortableParameterlessCallable otherwise. + this.flushAndSnapshot(); + } catch (error) { + // The above code is critical for saving volume data. Because the + // code is invoked asynchronously, there won't be a default error + // handling in case it crashes due to a bug. + // Therefore, escalate the error manually so that the sagas will crash + // (notifying the user and stopping further potentially undefined behavior). + Store.dispatch(escalateErrorAction(error)); } }; - push = _.debounce(this.pushImpl, PUSH_DEBOUNCE_TIME, { - maxWait: PUSH_DEBOUNCE_MAX_WAIT_TIME, - }); + private flushAndSnapshot() { + this.waitTimeStartTimeStamp = null; + // Flush pendingBuckets. Note that it's important to do this synchronously. + // Otherwise, other actors might add to pendingBuckets concurrently during the flush, + // causing an inconsistent state for a transaction. + const batch: DataBucket[] = Array.from(this.pendingBuckets); + this.pendingBuckets = new Set(); + + // Fire and forget. The correct transaction ordering is ensured + // within pushTransaction. + this.pushTransaction(batch); + } + + push = createDebouncedAbortableParameterlessCallable(this.pushImpl, PUSH_DEBOUNCE_TIME, this); - pushBatch(batch: Array): Promise { - return sendToStore(batch, this.cube.layerName); + async pushTransaction(batch: Array): Promise { + /* + * Create a transaction from the batch and push it into the save queue. + */ + try { + this.compressingBucketCount += batch.length; + + // Start the compression job. Note that an older invocation of + // createCompressedUpdateBucketActions might still be running. + // We can still *start* a new compression job, but we want to ensure + // that the jobs are processed in the order they were initiated. + // This is done using orderedWaitFor. + // Addendum: + // In practice, this won't matter much since compression jobs + // are processed by a pool of webworkers in fifo-order, anyway. + // However, there is a theoretical chance of a race condition, + // since the fifo-ordering is only ensured for starting the webworker + // and not for receiving the return values. + const items = await this.fifoResolver.orderedWaitFor( + createCompressedUpdateBucketActions(batch), + ); + Store.dispatch(pushSaveQueueTransaction(items, "volume", this.cube.layerName)); + + this.compressingBucketCount -= batch.length; + } catch (error) { + // See other usage of escalateErrorAction for a detailed explanation. + Store.dispatch(escalateErrorAction(error)); + } } } diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/temporal_bucket_manager.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/temporal_bucket_manager.ts index a4c94fafca7..5de198998e1 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/temporal_bucket_manager.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/temporal_bucket_manager.ts @@ -51,9 +51,9 @@ class TemporalBucketManager { return loadedPromise; } - async getAllLoadedPromise(): Promise { + getAllLoadedPromise = async () => { await Promise.all(this.loadedPromises); - } + }; } export default TemporalBucketManager; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/wkstore_adapter.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/wkstore_adapter.ts index c2c24028316..e31ebbe4c26 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/wkstore_adapter.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/wkstore_adapter.ts @@ -10,28 +10,34 @@ import { } from "oxalis/model/accessors/dataset_accessor"; import { getVolumeTracingById } from "oxalis/model/accessors/volumetracing_accessor"; import { parseAsMaybe } from "libs/utils"; -import { pushSaveQueueTransaction } from "oxalis/model/actions/save_actions"; import type { UpdateAction } from "oxalis/model/sagas/update_actions"; import { updateBucket } from "oxalis/model/sagas/update_actions"; -import ByteArrayToLz4Base64Worker from "oxalis/workers/byte_array_to_lz4_base64.worker"; +import ByteArraysToLz4Base64Worker from "oxalis/workers/byte_arrays_to_lz4_base64.worker"; import DecodeFourBitWorker from "oxalis/workers/decode_four_bit.worker"; import ErrorHandling from "libs/error_handling"; import Request from "libs/request"; import type { DataLayerType, VolumeTracing } from "oxalis/store"; import Store from "oxalis/store"; -import WorkerPool from "libs/worker_pool"; +import WebworkerPool from "libs/webworker_pool"; import type { Vector3, Vector4 } from "oxalis/constants"; import constants, { MappingStatusEnum } from "oxalis/constants"; import window from "libs/window"; import { getGlobalDataConnectionInfo } from "../data_connection_info"; import { ResolutionInfo } from "../helpers/resolution_info"; +import _ from "lodash"; const decodeFourBit = createWorker(DecodeFourBitWorker); + +// For 32-bit buckets with 32^3 voxels, a COMPRESSION_BATCH_SIZE of +// 128 corresponds to 16.8 MB that are sent to a webworker in one +// go. +const COMPRESSION_BATCH_SIZE = 128; const COMPRESSION_WORKER_COUNT = 2; -const compressionPool = new WorkerPool( - () => createWorker(ByteArrayToLz4Base64Worker), +const compressionPool = new WebworkerPool( + () => createWorker(ByteArraysToLz4Base64Worker), COMPRESSION_WORKER_COUNT, ); + export const REQUEST_TIMEOUT = 60000; export type SendBucketInfo = { position: Vector3; @@ -238,15 +244,24 @@ function sliceBufferIntoPieces( return bucketBuffers; } -export async function sendToStore(batch: Array, tracingId: string): Promise { - const items: Array = await Promise.all( - batch.map(async (bucket): Promise => { - const data = bucket.getCopyOfData(); - const bucketInfo = createSendBucketInfo(bucket.zoomedAddress, bucket.cube.resolutionInfo); - const byteArray = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const compressedBase64 = await compressionPool.submit(byteArray); - return updateBucket(bucketInfo, compressedBase64); - }), +export async function createCompressedUpdateBucketActions( + batch: Array, +): Promise { + return _.flatten( + await Promise.all( + _.chunk(batch, COMPRESSION_BATCH_SIZE).map(async (batchSubset) => { + const byteArrays = batchSubset.map((bucket) => { + const data = bucket.getCopyOfData(); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + }); + + const compressedBase64Strings = await compressionPool.submit(byteArrays); + return compressedBase64Strings.map((compressedBase64, index) => { + const bucket = batchSubset[index]; + const bucketInfo = createSendBucketInfo(bucket.zoomedAddress, bucket.cube.resolutionInfo); + return updateBucket(bucketInfo, compressedBase64); + }); + }), + ), ); - Store.dispatch(pushSaveQueueTransaction(items, "volume", tracingId)); } diff --git a/frontend/javascripts/oxalis/model/reducers/save_reducer.ts b/frontend/javascripts/oxalis/model/reducers/save_reducer.ts index 07dafbac2f9..92c2a9473a5 100644 --- a/frontend/javascripts/oxalis/model/reducers/save_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/save_reducer.ts @@ -10,7 +10,7 @@ import type { } from "oxalis/model/actions/save_actions"; import { getActionLog } from "oxalis/model/helpers/action_logger_middleware"; import { getStats } from "oxalis/model/accessors/skeletontracing_accessor"; -import { maximumActionCountPerBatch } from "oxalis/model/sagas/save_saga_constants"; +import { MAXIMUM_ACTION_COUNT_PER_BATCH } from "oxalis/model/sagas/save_saga_constants"; import { selectQueue } from "oxalis/model/accessors/save_accessor"; import { updateKey2 } from "oxalis/model/helpers/deep_update"; import { @@ -123,7 +123,10 @@ function SaveReducer(state: OxalisState, action: Action): OxalisState { throw new Error("Tried to save something even though user is not logged in."); } - const updateActionChunks = _.chunk(items, maximumActionCountPerBatch); + const updateActionChunks = _.chunk( + items, + MAXIMUM_ACTION_COUNT_PER_BATCH[action.saveQueueType], + ); const transactionGroupCount = updateActionChunks.length; const actionLogInfo = JSON.stringify(getActionLog().slice(-10)); diff --git a/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts b/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts index 25a461f3bbd..d0b153ebe73 100644 --- a/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts @@ -5,7 +5,7 @@ import { chunkDynamically, sleep } from "libs/utils"; import ErrorHandling from "libs/error_handling"; import type { APIDataset, APIMeshFile, APISegmentationLayer } from "types/api_flow_types"; import { mergeBufferGeometries, mergeVertices } from "libs/BufferGeometryUtils"; -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; import Store from "oxalis/store"; import { @@ -70,7 +70,7 @@ import { saveNowAction } from "oxalis/model/actions/save_actions"; import Toast from "libs/toast"; import { getDracoLoader } from "libs/draco"; import messages from "messages"; -import processTaskWithPool from "libs/task_pool"; +import processTaskWithPool from "libs/async/task_pool"; import { getBaseSegmentationName } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; import { RemoveSegmentAction, UpdateSegmentAction } from "../actions/volumetracing_actions"; import { ResolutionInfo } from "../helpers/resolution_info"; diff --git a/frontend/javascripts/oxalis/model/sagas/root_saga.ts b/frontend/javascripts/oxalis/model/sagas/root_saga.ts index 6b0cef8ed36..bf0b715305a 100644 --- a/frontend/javascripts/oxalis/model/sagas/root_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/root_saga.ts @@ -1,5 +1,5 @@ import type { Saga } from "oxalis/model/sagas/effect-generators"; -import { all, call, cancel, fork, take } from "typed-redux-saga"; +import { all, call, cancel, fork, take, takeEvery } from "typed-redux-saga"; import { alert } from "libs/window"; import VolumetracingSagas from "oxalis/model/sagas/volumetracing_saga"; import SaveSagas, { toggleErrorHighlighting } from "oxalis/model/sagas/save_saga"; @@ -19,6 +19,7 @@ import MappingSaga from "oxalis/model/sagas/mapping_saga"; import ProofreadSaga from "oxalis/model/sagas/proofread_saga"; import { listenForWkReady } from "oxalis/model/sagas/wk_ready_saga"; import { warnIfEmailIsUnverified } from "./user_saga"; +import { EscalateErrorAction } from "../actions/actions"; let rootSagaCrashed = false; export default function* rootSaga(): Saga { @@ -33,6 +34,15 @@ export function hasRootSagaCrashed() { return rootSagaCrashed; } +function* listenToErrorEscalation() { + // Make the saga deliberately crash when this action has been + // dispatched. This should be used if an error was thrown in a + // critical place, which should stop further saga saving. + yield* takeEvery("ESCALATE_ERROR", (action: EscalateErrorAction) => { + throw action.error; + }); +} + function* restartableSaga(): Saga { try { yield* all([ @@ -55,6 +65,7 @@ function* restartableSaga(): Saga { ...VolumetracingSagas.map((saga) => call(saga)), call(watchZ1Downsampling), call(warnIfEmailIsUnverified), + call(listenToErrorEscalation), ]); } catch (err) { rootSagaCrashed = true; diff --git a/frontend/javascripts/oxalis/model/sagas/save_saga.ts b/frontend/javascripts/oxalis/model/sagas/save_saga.ts index 89d8ce97107..c46a4e79289 100644 --- a/frontend/javascripts/oxalis/model/sagas/save_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/save_saga.ts @@ -36,7 +36,7 @@ import { globalPositionToBucketPosition } from "oxalis/model/helpers/position_co import type { Saga } from "oxalis/model/sagas/effect-generators"; import { select } from "oxalis/model/sagas/effect-generators"; import { - maximumActionCountPerSave, + MAXIMUM_ACTION_COUNT_PER_SAVE, MAX_SAVE_RETRY_WAITING_TIME, PUSH_THROTTLE_TIME, SAVE_RETRY_WAITING_TIME, @@ -73,7 +73,7 @@ export function* pushSaveQueueAsync(saveQueueType: SaveQueueType, tracingId: str if (saveQueue.length === 0) { if (loopCounter % 100 === 0) { - // See https://github.com/scalableminds/webknossos/pull/6076 for an explanation + // See https://github.com/scalableminds/webknossos/pull/6076 (or 82e16e1) for an explanation // of this delay call. yield* delay(0); } @@ -88,27 +88,35 @@ export function* pushSaveQueueAsync(saveQueueType: SaveQueueType, tracingId: str }); yield* put(setSaveBusyAction(true, saveQueueType)); - if (forcePush) { - while (true) { - // Send batches to the server until the save queue is empty. - saveQueue = yield* select((state) => selectQueue(state, saveQueueType, tracingId)); - - if (saveQueue.length > 0) { - yield* call(sendRequestToServer, saveQueueType, tracingId); - } else { - break; - } - } - } else { + // Send (parts) of the save queue to the server. + // There are two main cases: + // 1) forcePush is true + // The user explicitly requested to save an annotation. + // In this case, batches are sent to the server until the save + // queue is empty. Note that the save queue might be added to + // while saving is in progress. Still, the save queue will be + // drained until it is empty. If the user hits save and continuously + // annotates further, a high number of save-requests might be sent. + // 2) forcePush is false + // The auto-save interval was reached at time T. The following code + // will determine how many items are in the save queue at this time T. + // Exactly that many items will be sent to the server. + // New items that might be added to the save queue during saving, will + // ignored (they will be picked up in the next iteration of this loop). + // Otherwise, the risk of a high number of save-requests (see case 1) + // would be present here, too (note the risk would be greater, because the + // user didn't use the save button which is usually accompanied a small pause). + const itemCountToSave = forcePush + ? Infinity + : yield* select((state) => selectQueue(state, saveQueueType, tracingId).length); + let savedItemCount = 0; + while (savedItemCount < itemCountToSave) { saveQueue = yield* select((state) => selectQueue(state, saveQueueType, tracingId)); if (saveQueue.length > 0) { - // Saving the tracing automatically (via timeout) only saves the current state. - // It does not require to reach an empty saveQueue. This is especially - // important when the auto-saving happens during continuous movements. - // Always draining the save queue completely would mean that save - // requests are sent as long as the user moves. - yield* call(sendRequestToServer, saveQueueType, tracingId); + savedItemCount += yield* call(sendRequestToServer, saveQueueType, tracingId); + } else { + break; } } @@ -123,15 +131,18 @@ export function sendRequestWithToken( } // This function returns the first n batches of the provided array, so that the count of -// all actions in these n batches does not exceed maximumActionCountPerSave -function sliceAppropriateBatchCount(batches: Array): Array { +// all actions in these n batches does not exceed MAXIMUM_ACTION_COUNT_PER_SAVE +function sliceAppropriateBatchCount( + batches: Array, + saveQueueType: SaveQueueType, +): Array { const slicedBatches = []; let actionCount = 0; for (const batch of batches) { const newActionCount = actionCount + batch.actions.length; - if (newActionCount <= maximumActionCountPerSave) { + if (newActionCount <= MAXIMUM_ACTION_COUNT_PER_SAVE[saveQueueType]) { actionCount = newActionCount; slicedBatches.push(batch); } else { @@ -151,9 +162,18 @@ function getRetryWaitTime(retryCount: number) { // at any time, because the browser page is reloaded after the message is shown, anyway. let didShowFailedSimultaneousTracingError = false; -export function* sendRequestToServer(saveQueueType: SaveQueueType, tracingId: string): Saga { +export function* sendRequestToServer( + saveQueueType: SaveQueueType, + tracingId: string, +): Saga { + /* + * Saves a reasonably-sized part of the save queue (that corresponds to the + * tracingId) to the server (plus retry-mechanism). + * The saga returns the number of save queue items that were saved. + */ + const fullSaveQueue = yield* select((state) => selectQueue(state, saveQueueType, tracingId)); - const saveQueue = sliceAppropriateBatchCount(fullSaveQueue); + const saveQueue = sliceAppropriateBatchCount(fullSaveQueue, saveQueueType); let compactedSaveQueue = compactSaveQueue(saveQueue); const { version, type } = yield* select((state) => selectTracing(state, saveQueueType, tracingId), @@ -162,6 +182,7 @@ export function* sendRequestToServer(saveQueueType: SaveQueueType, tracingId: st compactedSaveQueue = addVersionNumbers(compactedSaveQueue, version); let retryCount = 0; + // This while-loop only exists for the purpose of a retry-mechanism while (true) { let exceptionDuringMarkBucketsAsNotDirty = false; @@ -205,7 +226,7 @@ export function* sendRequestToServer(saveQueueType: SaveQueueType, tracingId: st } yield* call(toggleErrorHighlighting, false); - return; + return saveQueue.length; } catch (error) { if (exceptionDuringMarkBucketsAsNotDirty) { throw error; diff --git a/frontend/javascripts/oxalis/model/sagas/save_saga_constants.ts b/frontend/javascripts/oxalis/model/sagas/save_saga_constants.ts index 233d31fed01..28d90432790 100644 --- a/frontend/javascripts/oxalis/model/sagas/save_saga_constants.ts +++ b/frontend/javascripts/oxalis/model/sagas/save_saga_constants.ts @@ -11,5 +11,14 @@ export const UNDO_HISTORY_SIZE = 20; export const SETTINGS_RETRY_DELAY = 15 * 1000; export const SETTINGS_MAX_RETRY_COUNT = 20; // 20 * 15s == 5m -export const maximumActionCountPerBatch = 5000; -export const maximumActionCountPerSave = 15000; +export const MAXIMUM_ACTION_COUNT_PER_BATCH = { + skeleton: 5000, + volume: 1000, // Since volume saving is slower, use a lower value here. + mapping: 5000, +} as const; + +export const MAXIMUM_ACTION_COUNT_PER_SAVE = { + skeleton: 15000, + volume: 3000, + mapping: 15000, +} as const; diff --git a/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts b/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts index 3a7a6bc8343..5c4cec475f3 100644 --- a/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts +++ b/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts @@ -80,7 +80,7 @@ function upsampleVoxelMap( ]); if (currentGoalBucket.type === "null") { - console.warn(warnAboutCouldNotCreate([...currentGoalBucketAddress, targetZoomStep])); + warnAboutCouldNotCreate([...currentGoalBucketAddress, targetZoomStep]); continue; } diff --git a/frontend/javascripts/oxalis/throttled_store.ts b/frontend/javascripts/oxalis/throttled_store.ts index 64bd969706d..5e6241f3f3c 100644 --- a/frontend/javascripts/oxalis/throttled_store.ts +++ b/frontend/javascripts/oxalis/throttled_store.ts @@ -1,6 +1,6 @@ /* eslint no-await-in-loop: 0 */ import type { Store as StoreType } from "redux"; -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; import type { OxalisState } from "oxalis/store"; import Store from "oxalis/store"; import * as Utils from "libs/utils"; diff --git a/frontend/javascripts/oxalis/view/action-bar/save_button.tsx b/frontend/javascripts/oxalis/view/action-bar/save_button.tsx index 9a6cdb83017..d59ba52de7b 100644 --- a/frontend/javascripts/oxalis/view/action-bar/save_button.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/save_button.tsx @@ -1,7 +1,7 @@ import { connect } from "react-redux"; import React from "react"; import _ from "lodash"; -import Store from "oxalis/store"; +import Store, { SaveState } from "oxalis/store"; import type { OxalisState, IsBusyInfo } from "oxalis/store"; import { isBusy } from "oxalis/model/accessors/save_accessor"; import ButtonComponent from "oxalis/view/components/button_component"; @@ -17,7 +17,7 @@ import { import ErrorHandling from "libs/error_handling"; import * as Utils from "libs/utils"; type OwnProps = { - onClick: (arg0: React.SyntheticEvent) => Promise; + onClick: (arg0: React.MouseEvent) => Promise; className?: string; }; type StateProps = { @@ -29,6 +29,7 @@ type State = { isStateSaved: boolean; showUnsavedWarning: boolean; saveInfo: { + outstandingBucketDownloadCount: number; compressingBucketCount: number; waitingForCompressionBucketCount: number; }; @@ -55,6 +56,7 @@ class SaveButton extends React.PureComponent { isStateSaved: false, showUnsavedWarning: false, saveInfo: { + outstandingBucketDownloadCount: 0, compressingBucketCount: 0, waitingForCompressionBucketCount: 0, }, @@ -72,20 +74,27 @@ class SaveButton extends React.PureComponent { _forceUpdate = () => { const isStateSaved = Model.stateSaved(); const oldestUnsavedTimestamp = getOldestUnsavedTimestamp(Store.getState().save.queue); - const unsavedDuration = - // @ts-expect-error ts-migrate(2362) FIXME: The left-hand side of an arithmetic operation must... Remove this comment to see the full error message - oldestUnsavedTimestamp != null ? new Date() - oldestUnsavedTimestamp : 0; + + const unsavedDuration = Math.max( + oldestUnsavedTimestamp != null ? Date.now() - oldestUnsavedTimestamp : 0, + Model.getLongestPushQueueWaitTime(), + ); const showUnsavedWarning = unsavedDuration > UNSAVED_WARNING_THRESHOLD; if (showUnsavedWarning) { reportUnsavedDurationThresholdExceeded(); } - const { compressingBucketCount, waitingForCompressionBucketCount } = Model.getPushQueueStats(); + const { + compressingBucketCount, + waitingForCompressionBucketCount, + outstandingBucketDownloadCount, + } = Model.getPushQueueStats(); this.setState({ isStateSaved, showUnsavedWarning, saveInfo: { + outstandingBucketDownloadCount, compressingBucketCount, waitingForCompressionBucketCount, }, @@ -109,11 +118,12 @@ class SaveButton extends React.PureComponent { render() { const { progressFraction } = this.props; const { showUnsavedWarning } = this.state; + const { outstandingBucketDownloadCount } = this.state.saveInfo; + const totalBucketsToCompress = this.state.saveInfo.waitingForCompressionBucketCount + this.state.saveInfo.compressingBucketCount; return ( - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. { icon={this.getSaveButtonIcon()} className={this.props.className} style={{ - background: showUnsavedWarning ? "var(--ant-error)" : null, + background: showUnsavedWarning ? "var(--ant-error)" : undefined, }} > 0 + // Downloading the buckets often takes longer and the progress + // is visible (as the count will decrease continually). + // If lots of buckets need compression, this can also take a bit. + // Don't show both labels at the same time, because the compression + // usually can only start after the download is finished. + outstandingBucketDownloadCount > 0 + ? `${outstandingBucketDownloadCount} items remaining to download...` + : totalBucketsToCompress > 0 ? `${totalBucketsToCompress} items remaining to compress...` : null } @@ -159,8 +176,7 @@ class SaveButton extends React.PureComponent { } } -// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'saveQueue' implicitly has an 'any' type... Remove this comment to see the full error message -function getOldestUnsavedTimestamp(saveQueue): number | null | undefined { +function getOldestUnsavedTimestamp(saveQueue: SaveState["queue"]): number | null | undefined { let oldestUnsavedTimestamp; if (saveQueue.skeleton.length > 0) { @@ -168,9 +184,7 @@ function getOldestUnsavedTimestamp(saveQueue): number | null | undefined { } for (const volumeQueue of Utils.values(saveQueue.volumes)) { - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. if (volumeQueue.length > 0) { - // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. const oldestVolumeTimestamp = volumeQueue[0].timestamp; oldestUnsavedTimestamp = Math.min( oldestUnsavedTimestamp != null ? oldestUnsavedTimestamp : Infinity, diff --git a/frontend/javascripts/oxalis/workers/byte_array_to_lz4_base64.worker.ts b/frontend/javascripts/oxalis/workers/byte_arrays_to_lz4_base64.worker.ts similarity index 57% rename from frontend/javascripts/oxalis/workers/byte_array_to_lz4_base64.worker.ts rename to frontend/javascripts/oxalis/workers/byte_arrays_to_lz4_base64.worker.ts index 618cb91049d..1cb2b323ea3 100644 --- a/frontend/javascripts/oxalis/workers/byte_array_to_lz4_base64.worker.ts +++ b/frontend/javascripts/oxalis/workers/byte_arrays_to_lz4_base64.worker.ts @@ -10,8 +10,13 @@ function compressLz4Block(data: Uint8Array): Uint8Array { return newCompressed.slice(4); } -export function byteArrayToLz4Base64(byteArray: Uint8Array): string { - const compressed = compressLz4Block(byteArray); - return Base64.fromByteArray(compressed); +export function byteArraysToLz4Base64(byteArrays: Uint8Array[]): string[] { + const base64Strings: string[] = []; + for (const byteArray of byteArrays) { + const compressed = compressLz4Block(byteArray); + base64Strings.push(Base64.fromByteArray(compressed)); + } + + return base64Strings; } -export default expose(byteArrayToLz4Base64); +export default expose(byteArraysToLz4Base64); diff --git a/frontend/javascripts/test/libs/async_fifo_resolver.spec.ts b/frontend/javascripts/test/libs/async_fifo_resolver.spec.ts new file mode 100644 index 00000000000..53db8fd27e0 --- /dev/null +++ b/frontend/javascripts/test/libs/async_fifo_resolver.spec.ts @@ -0,0 +1,109 @@ +import { sleep } from "libs/utils"; +import test from "ava"; +import _ from "lodash"; +import { AsyncFifoResolver } from "libs/async/async_fifo_resolver"; + +const createSubmitterFnWithProtocol = () => { + const resolver = new AsyncFifoResolver(); + const protocol: string[] = []; + + async function submitter(id: number, duration: number) { + protocol.push(`started-${id}`); + await resolver.orderedWaitFor( + sleep(duration).then(() => protocol.push(`sleep-finished-${id}`)), + ); + protocol.push(`finished-${id}`); + } + + return { submitter, resolver, protocol }; +}; + +test("AsyncFifoResolver: Test simplest case", async (t) => { + t.plan(2); + + const { submitter, resolver, protocol } = createSubmitterFnWithProtocol(); + submitter(1, 10); + submitter(2, 10); + + // Wait until everything is done + await resolver.orderedWaitFor(Promise.resolve()); + + t.deepEqual(protocol, [ + "started-1", + "started-2", + "sleep-finished-1", + "finished-1", + "sleep-finished-2", + "finished-2", + ]); + t.is(resolver.queue.length, 0); +}); + +test("AsyncFifoResolver: Test out-of-order sleeps should still finish in order", async (t) => { + t.plan(2); + + const { submitter, resolver, protocol } = createSubmitterFnWithProtocol(); + submitter(1, 50); + submitter(2, 10); + + // Wait until everything is done + await resolver.orderedWaitFor(Promise.resolve()); + + t.deepEqual(protocol, [ + "started-1", + "started-2", + "sleep-finished-2", + "sleep-finished-1", + "finished-1", + "finished-2", + ]); + t.is(resolver.queue.length, 0); +}); + +test("AsyncFifoResolver: New submits shouldn't block old ones.", async (t) => { + t.plan(2); + + const { submitter, resolver, protocol } = createSubmitterFnWithProtocol(); + // The first submitter should finish through and should not be blocked + // by the second one. + submitter(1, 50); + submitter(2, 1000); + + await sleep(50); + + t.deepEqual(protocol, ["started-1", "started-2", "sleep-finished-1", "finished-1"]); + t.is(resolver.queue.length, 1); +}); + +test("AsyncFifoResolver: Trimming of queue should work despite race condition potential.", async (t) => { + t.plan(3); + + const { submitter, resolver, protocol } = createSubmitterFnWithProtocol(); + + submitter(1, 100); + const promise = submitter(2, 100); + t.is(resolver.queue.length, 2); + submitter(3, 1000); + + await promise; + submitter(4, 1); + + // Wait until everything is done + await resolver.orderedWaitFor(Promise.resolve()); + + t.deepEqual(protocol, [ + "started-1", + "started-2", + "started-3", + "sleep-finished-1", + "finished-1", + "sleep-finished-2", + "finished-2", + "started-4", + "sleep-finished-4", + "sleep-finished-3", + "finished-3", + "finished-4", + ]); + t.is(resolver.queue.length, 0); +}); diff --git a/frontend/javascripts/test/libs/async_task_queue.spec.ts b/frontend/javascripts/test/libs/async_task_queue.spec.ts deleted file mode 100644 index b98adaaf3f9..00000000000 --- a/frontend/javascripts/test/libs/async_task_queue.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-nocheck -import AsyncTaskQueue from "libs/async_task_queue"; -import Deferred from "libs/deferred"; -import * as Utils from "libs/utils"; -import sinon from "sinon"; -import test from "ava"; -test("AsyncTaskQueue should run a task (1/2)", async (t) => { - t.plan(1); - const queue = new AsyncTaskQueue(); - const task = new Deferred(); - queue.scheduleTask(task.task()); - await Utils.sleep(1); - t.is(queue.isBusy(), true); - task.resolve(); - await queue.join(); -}); -test("AsyncTaskQueue should run a task (2/2)", async (t) => { - t.plan(2); - const queue = new AsyncTaskQueue(); - const task = new Deferred(); - const result = "foo"; - const handle = queue.scheduleTask(task.task()); - await Utils.sleep(1); - t.is(queue.isBusy(), true); - task.resolve(result); - t.deepEqual(await handle, result); -}); -test("AsyncTaskQueue should fail the queue on a failed task", async (t) => { - t.plan(4); - const result = new Error("foo"); - const queue = new AsyncTaskQueue(0); - const task = new Deferred(); - const catcherBox = { - do: () => {}, - }; - sinon.spy(catcherBox, "do"); - const handle = queue.scheduleTask(task.task()); - handle.catch(catcherBox.do); - await Utils.sleep(1); - t.is(queue.isBusy(), true); - task.reject(result); - - try { - await queue.join(); - } catch (err) { - t.true(catcherBox.do.calledWith(result)); - t.is(err, result); - t.is(queue.failed, true); - } -}); -test("AsyncTaskQueue should seralize task execution", async (t) => { - t.plan(1); - const queue = new AsyncTaskQueue(0); - const task1 = new Deferred(); - const task2 = new Deferred(); - const taskLog = []; - const handle1 = queue.scheduleTask(task1.task()); - handle1.then(() => taskLog.push(1)); - const handle2 = queue.scheduleTask(task2.task()); - handle2.then(() => taskLog.push(2)); - await Utils.sleep(1); - task2.resolve(); - task1.resolve(); - await queue.join(); - t.deepEqual(taskLog, [1, 2]); -}); -test.serial("AsyncTaskQueue should retry failed tasks (1/2)", async (t) => { - t.plan(3); - const result = new Error("foo"); - const queue = new AsyncTaskQueue(3, 1); - const deferredBox = { - value: new Deferred(), - }; - - const task = () => deferredBox.value.promise(); - - const catcherBox = { - do: () => {}, - }; - sinon.spy(catcherBox, "do"); - const handle = queue.scheduleTask(task); - handle.catch(catcherBox.do); - - for (let i = 0; i < 3; i++) { - // eslint-disable-next-line no-await-in-loop - await Utils.sleep(5); - deferredBox.value.reject(result); - deferredBox.value = new Deferred(); - } - - try { - await queue.join(); - } catch (err) { - t.true(catcherBox.do.calledWith(result)); - t.is(err, result); - t.is(queue.failed, true); - } -}); -test.serial("AsyncTaskQueue should retry failed tasks (2/2)", async (t) => { - t.plan(1); - const result = new Error("foo"); - const queue = new AsyncTaskQueue(3, 1); - const deferredBox = { - value: new Deferred(), - }; - - const task = () => deferredBox.value.promise(); - - const handle = queue.scheduleTask(task); - - for (let i = 0; i < 2; i++) { - // eslint-disable-next-line no-await-in-loop - await Utils.sleep(5); - deferredBox.value.reject(result); - deferredBox.value = new Deferred(); - } - - await Utils.sleep(5); - deferredBox.value.resolve(); - await handle; - await queue.join(); - // ensure that we get to this point - t.pass(); -}); diff --git a/frontend/javascripts/test/libs/debounced_abortable_saga.spec.ts b/frontend/javascripts/test/libs/debounced_abortable_saga.spec.ts new file mode 100644 index 00000000000..2aa8330ce44 --- /dev/null +++ b/frontend/javascripts/test/libs/debounced_abortable_saga.spec.ts @@ -0,0 +1,87 @@ +import { type Saga } from "oxalis/model/sagas/effect-generators"; +import { sleep } from "libs/utils"; +import test from "ava"; +import _ from "lodash"; +import { createDebouncedAbortableCallable } from "libs/async/debounced_abortable_saga"; + +const createAbortableFnWithProtocol = () => { + const protocol: string[] = []; + function* abortableFn(msg: { id: number }): Saga { + protocol.push(`await-${msg.id}`); + yield sleep(1000); + protocol.push(`run-${msg.id}`); + } + + return { abortableFn, protocol }; +}; + +const DEBOUNCE_THRESHOLD = 100; +test("DebouncedAbortableSaga: Test simplest case", async (t) => { + t.plan(1); + const { abortableFn, protocol } = createAbortableFnWithProtocol(); + const fn = createDebouncedAbortableCallable(abortableFn, DEBOUNCE_THRESHOLD, this); + + fn({ id: 1 }); + + await sleep(2000); + t.deepEqual(protocol, ["await-1", "run-1"]); +}); + +test("DebouncedAbortableSaga: Rapid calls where the last one should win", async (t) => { + t.plan(1); + const { abortableFn, protocol } = createAbortableFnWithProtocol(); + const fn = createDebouncedAbortableCallable(abortableFn, DEBOUNCE_THRESHOLD, this); + + fn({ id: 1 }); + fn({ id: 2 }); + fn({ id: 3 }); + + await sleep(2000); + t.deepEqual(protocol, ["await-3", "run-3"]); +}); + +test("DebouncedAbortableSaga: Rapid calls with small breaks", async (t) => { + t.plan(1); + const { abortableFn, protocol } = createAbortableFnWithProtocol(); + const fn = createDebouncedAbortableCallable(abortableFn, DEBOUNCE_THRESHOLD, this); + + // Rapid calls with small breaks in-between. + // The small breaks are long enough to satisfy the debounding, + // but not long enough to let the awaiting through. + fn({ id: 1 }); + fn({ id: 2 }); + fn({ id: 3 }); + await sleep(150); + fn({ id: 4 }); + fn({ id: 5 }); + await sleep(150); + fn({ id: 6 }); + fn({ id: 7 }); + + await sleep(2000); + t.deepEqual(protocol, ["await-3", "await-5", "await-7", "run-7"]); +}); + +test.skip("High volume calls", async () => { + // This is not a unit test, but rather a small speed test + // to make a note that the classic _.debounce is way faster. + // For 1000 invocations, _.debounce is roughly 10x faster. + // However, this probably not a bottleneck right now. + + const lodashDebounced = _.debounce((_obj: Object) => {}); + + const { abortableFn } = createAbortableFnWithProtocol(); + const fn = createDebouncedAbortableCallable(abortableFn, DEBOUNCE_THRESHOLD, this); + + console.time("Benchmarking debounce stuff"); + for (let i = 0; i < 1000; i++) { + fn({ id: i }); + } + console.timeEnd("Benchmarking debounce stuff"); + + console.time("Benchmarking lodash debounce stuff"); + for (let i = 0; i < 1000; i++) { + lodashDebounced({ id: i }); + } + console.timeEnd("Benchmarking lodash debounce stuff"); +}); diff --git a/frontend/javascripts/test/libs/deferred.spec.ts b/frontend/javascripts/test/libs/deferred.spec.ts index 3e32c1432aa..0089c676826 100644 --- a/frontend/javascripts/test/libs/deferred.spec.ts +++ b/frontend/javascripts/test/libs/deferred.spec.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; import runAsync from "test/helpers/run-async"; import test from "ava"; diff --git a/frontend/javascripts/test/libs/latest_task_executor.spec.ts b/frontend/javascripts/test/libs/latest_task_executor.spec.ts index 1226062a05d..2e56c4c8d46 100644 --- a/frontend/javascripts/test/libs/latest_task_executor.spec.ts +++ b/frontend/javascripts/test/libs/latest_task_executor.spec.ts @@ -1,13 +1,13 @@ -import Deferred from "libs/deferred"; +import Deferred from "libs/async/deferred"; import test from "ava"; -import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/latest_task_executor"; -// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. +import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/async/latest_task_executor"; + test("LatestTaskExecutor: One task", async (t) => { const executor = new LatestTaskExecutor(); const deferred1 = new Deferred(); const scheduledPromise = executor.schedule(() => deferred1.promise()); deferred1.resolve(true); - return scheduledPromise.then((result) => { + await scheduledPromise.then((result) => { t.true(result); }); }); diff --git a/frontend/javascripts/test/libs/task_pool.spec.ts b/frontend/javascripts/test/libs/task_pool.spec.ts index d4243dcb4b7..3c9aedbcf6a 100644 --- a/frontend/javascripts/test/libs/task_pool.spec.ts +++ b/frontend/javascripts/test/libs/task_pool.spec.ts @@ -1,6 +1,6 @@ import { call, type Saga } from "oxalis/model/sagas/effect-generators"; import { runSaga } from "redux-saga"; -import processTaskWithPool from "libs/task_pool"; +import processTaskWithPool from "libs/async/task_pool"; import * as Utils from "libs/utils"; import test from "ava"; diff --git a/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts b/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts index 0ea4ab70260..badbdfbe361 100644 --- a/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts +++ b/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import "test/model/binary/layers/wkstore_adapter.mock.js"; import { getBitDepth } from "oxalis/model/accessors/dataset_accessor"; -import { byteArrayToLz4Base64 } from "oxalis/workers/byte_array_to_lz4_base64.worker"; +import { byteArraysToLz4Base64 } from "oxalis/workers/byte_arrays_to_lz4_base64.worker"; import datasetServerObject from "test/fixtures/dataset_server_object"; import mockRequire from "mock-require"; import sinon from "sinon"; @@ -57,7 +57,10 @@ const StoreMock = { mockRequire("libs/request", RequestMock); mockRequire("oxalis/store", StoreMock); const { DataBucket } = mockRequire.reRequire("oxalis/model/bucket_data_handling/bucket"); -const { requestWithFallback, sendToStore } = mockRequire.reRequire( + +const PushQueue = mockRequire.reRequire("oxalis/model/bucket_data_handling/pushqueue").default; + +const { requestWithFallback } = mockRequire.reRequire( "oxalis/model/bucket_data_handling/wkstore_adapter", ); const tokenResponse = { @@ -246,7 +249,7 @@ test.serial("sendToStore: Request Handling should send the correct request param position: [0, 0, 0], mag: [1, 1, 1], cubeSize: 32, - base64Data: byteArrayToLz4Base64(data), + base64Data: byteArraysToLz4Base64([data])[0], }, }, { @@ -255,7 +258,7 @@ test.serial("sendToStore: Request Handling should send the correct request param position: [64, 64, 64], mag: [2, 2, 2], cubeSize: 32, - base64Data: byteArrayToLz4Base64(data), + base64Data: byteArraysToLz4Base64([data])[0], }, }, ], @@ -263,7 +266,10 @@ test.serial("sendToStore: Request Handling should send the correct request param saveQueueType: "volume", tracingId, }; - return sendToStore(batch, tracingId).then(() => { + + const pushQueue = new PushQueue({ ...mockedCube, layerName: tracingId }); + + return pushQueue.pushTransaction(batch).then(() => { t.is(StoreMock.dispatch.callCount, 1); const [saveQueueItems] = StoreMock.dispatch.getCall(0).args; t.deepEqual(saveQueueItems, expectedSaveQueueItems); diff --git a/frontend/javascripts/test/sagas/saga_integration.spec.ts b/frontend/javascripts/test/sagas/saga_integration.spec.ts index 006f8ee8aab..38ee04bc297 100644 --- a/frontend/javascripts/test/sagas/saga_integration.spec.ts +++ b/frontend/javascripts/test/sagas/saga_integration.spec.ts @@ -5,7 +5,7 @@ import "test/sagas/saga_integration.mock.js"; import { __setupOxalis, TIMESTAMP } from "test/helpers/apiHelpers"; import { createSaveQueueFromUpdateActions } from "test/helpers/saveHelpers"; import { enforceSkeletonTracing, getStats } from "oxalis/model/accessors/skeletontracing_accessor"; -import { maximumActionCountPerBatch } from "oxalis/model/sagas/save_saga_constants"; +import { MAXIMUM_ACTION_COUNT_PER_BATCH } from "oxalis/model/sagas/save_saga_constants"; import { restartSagaAction, wkReadyAction } from "oxalis/model/actions/actions"; import Store from "oxalis/store"; import * as Utils from "libs/utils"; @@ -82,7 +82,10 @@ test.serial("Save actions should not be chunked below the chunk limit (1/3)", (t const trees = generateDummyTrees(1000, 1); Store.dispatch(addTreesAndGroupsAction(createTreeMapFromTreeArray(trees), [])); t.is(Store.getState().save.queue.skeleton.length, 1); - t.true(Store.getState().save.queue.skeleton[0].actions.length < maximumActionCountPerBatch); + t.true( + Store.getState().save.queue.skeleton[0].actions.length < + MAXIMUM_ACTION_COUNT_PER_BATCH.skeleton, + ); }); test.serial("Save actions should be chunked above the chunk limit (2/3)", (t) => { Store.dispatch(discardSaveQueuesAction()); @@ -91,7 +94,7 @@ test.serial("Save actions should be chunked above the chunk limit (2/3)", (t) => Store.dispatch(addTreesAndGroupsAction(createTreeMapFromTreeArray(trees), [])); const state = Store.getState(); t.true(state.save.queue.skeleton.length > 1); - t.is(state.save.queue.skeleton[0].actions.length, maximumActionCountPerBatch); + t.is(state.save.queue.skeleton[0].actions.length, MAXIMUM_ACTION_COUNT_PER_BATCH.skeleton); }); test.serial("Save actions should be chunked after compacting (3/3)", (t) => { const nodeCount = 20000; @@ -107,6 +110,6 @@ test.serial("Save actions should be chunked after compacting (3/3)", (t) => { const { skeleton: skeletonSaveQueue } = Store.getState().save.queue; // There should only be one chunk t.is(skeletonSaveQueue.length, 1); - t.true(skeletonSaveQueue[0].actions.length < maximumActionCountPerBatch); + t.true(skeletonSaveQueue[0].actions.length < MAXIMUM_ACTION_COUNT_PER_BATCH.skeleton); t.is(skeletonSaveQueue[0].actions[1].name, "moveTreeComponent"); }); diff --git a/frontend/javascripts/test/sagas/save_saga.spec.ts b/frontend/javascripts/test/sagas/save_saga.spec.ts index a655e266277..8999696c951 100644 --- a/frontend/javascripts/test/sagas/save_saga.spec.ts +++ b/frontend/javascripts/test/sagas/save_saga.spec.ts @@ -89,18 +89,23 @@ test("SaveSaga should send update actions", (t) => { expectValueDeepEqual(t, saga.next([]), take("PUSH_SAVE_QUEUE_TRANSACTION")); saga.next(); // race - saga.next({ - forcePush: SaveActions.saveNowAction(), - }); - saga.next(); // select state + expectValueDeepEqual( + t, + saga.next({ + forcePush: SaveActions.saveNowAction(), + }), + put(setSaveBusyAction(true, TRACING_TYPE)), + ); + + saga.next(); // advance to next select state expectValueDeepEqual(t, saga.next(saveQueue), call(sendRequestToServer, TRACING_TYPE, tracingId)); - saga.next(); // select state + saga.next(saveQueue.length); // select state expectValueDeepEqual(t, saga.next([]), put(setSaveBusyAction(false, TRACING_TYPE))); + // Test that loop repeats saga.next(); // select state - expectValueDeepEqual(t, saga.next([]), take("PUSH_SAVE_QUEUE_TRANSACTION")); }); test("SaveSaga should send request to server", (t) => { @@ -214,7 +219,7 @@ test("SaveSaga should send update actions right away and try to reach a state wh saga.next(saveQueue); // call sendRequestToServer - saga.next(); // select state + saga.next(1); // advance to select state expectValueDeepEqual(t, saga.next([]), put(setSaveBusyAction(false, TRACING_TYPE))); }); diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts index 68c483cdfa1..3e2f9052d99 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts @@ -953,7 +953,8 @@ test.serial("Undo for deleting segment group (with recursion)", async (t) => { const stateRestored = Store.getState(); const tracingRestored = stateRestored.tracing.volumes[0]; - t.is(tracingRestored.segmentGroups.length, 2); + t.is(tracingRestored.segmentGroups.length, 1); + t.is(tracingRestored.segmentGroups[0]?.children.length || 0, 1); t.is(tracingRestored.segments.size(), 4); t.is(tracingRestored.segments.get(1).groupId, 1); @@ -962,7 +963,7 @@ test.serial("Undo for deleting segment group (with recursion)", async (t) => { t.is(tracingRestored.segments.get(4).groupId, 2); }); -test.serial.only("Undo for deleting segment group (bug repro)", async (t) => { +test.serial("Undo for deleting segment group (bug repro)", async (t) => { const volumeTracingLayerName = t.context.api.data.getVolumeTracingLayerIds()[0]; const position = [1, 2, 3] as Vector3; Store.dispatch(clickSegmentAction(1, position)); diff --git a/tools/assert-no-test-only.sh b/tools/assert-no-test-only.sh index 4d726fbea90..0ee1f606a9b 100755 --- a/tools/assert-no-test-only.sh +++ b/tools/assert-no-test-only.sh @@ -1,4 +1,5 @@ #!/bin/bash echo "Checking for test.only() in test files." ! grep -r "test\.only(" frontend/javascripts/test || echo "Found test files with test.only() which disables other tests. Please remove the only modifier." +! grep -r "test\.serial\.only(" frontend/javascripts/test || echo "Found test files with test.only() which disables other tests. Please remove the only modifier." echo "Done"