Skip to content

Commit 723a5dc

Browse files
Fix race condition when starting proofreading with a split action (#8676)
When splitting an agglomerate in the proofreading mode while being in a saved state and without any editable mapping yet being created, wk used to crash. What happened? The mapping name was updated in the store. This would trigger the sagas that push update actions into the save queue. However, this did not happen synchronously. Instead, the proofreading saga would immediately call "ensureSavedState". As the save queue was not filled yet, this function call would immediately return. As a solution to this problem, this PR implements a ping-pong mechanism in which "ensureSavedState" dispatches an action to which all diffing sagas have to respond. By responding to the action, we know that the diffing sagas have already run. An alternative approach would have been to update some sort of version counter every time the annotation in the store changes. Then, the diffing sagas could store the newest version they have seen somewhere. The downside of this approach is that we would need to store these versions (and the "seen versions") per tracing. also, lots of store updates for these counters would happen. All of this can be avoided with the other approach described above. Additionally, this PR changes 4 saga tests from a generator-style to an integrated style where the saga middleware is used. ### URL of deployed dev instance (used for testing): - https://fixawaitsaving.webknossos.xyz ### Steps to test: - create a new annotation - use the proofreading tool to split a segment (do this while no editable mapping exists yet and while being in a saved state before clicking "split") ### Issues: - related to #8633 where the bug was initially observed ------ (Please delete unneeded items, merge only when none are left open) - [x] Updated [changelog](../blob/master/CHANGELOG.unreleased.md#unreleased) - [ ] Updated [migration guide](../blob/master/MIGRATIONS.unreleased.md#unreleased) if applicable - [ ] Updated [documentation](../blob/master/docs) if applicable - [ ] Adapted [wk-libs python client](https://github.com/scalableminds/webknossos-libs/tree/master/webknossos/webknossos/client) if relevant API parts change - [ ] Removed dev-only changes like prints and application.conf edits - [ ] Considered [common edge cases](../blob/master/.github/common_edge_cases.md) - [ ] Needs datastore update after deployment --------- Co-authored-by: Daniel <daniel.werner@scalableminds.com>
1 parent cc84297 commit 723a5dc

28 files changed

+296
-345
lines changed

frontend/javascripts/admin/rest_api.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import dayjs from "dayjs";
22
import { V3 } from "libs/mjs";
3-
import type { RequestOptions } from "libs/request";
3+
import type { RequestOptions, RequestOptionsWithData } from "libs/request";
44
import Request from "libs/request";
55
import type { Message } from "libs/toast";
66
import Toast from "libs/toast";
@@ -90,6 +90,7 @@ import type {
9090
MappingType,
9191
NumberLike,
9292
PartialDatasetConfiguration,
93+
SaveQueueEntry,
9394
StoreAnnotation,
9495
TraceOrViewCommand,
9596
UserConfiguration,
@@ -2398,3 +2399,12 @@ export function requestVerificationMail() {
23982399
method: "POST",
23992400
});
24002401
}
2402+
2403+
export function sendSaveRequestWithToken(
2404+
urlWithoutToken: string,
2405+
data: RequestOptionsWithData<Array<SaveQueueEntry>>,
2406+
): Promise<void> {
2407+
// Ideally, this function should be refactored further so that it generates the
2408+
// correct urlWithoutToken itself.
2409+
return doWithToken((token) => Request.sendJSONReceiveJSON(`${urlWithoutToken}${token}`, data));
2410+
}

frontend/javascripts/libs/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,3 +1303,11 @@ export function getPhraseFromCamelCaseString(stringInCamelCase: string): string
13031303
.map((word) => capitalize(word.replace(/(^|\s)td/, "$13D")))
13041304
.join(" ");
13051305
}
1306+
1307+
export function areSetsEqual<T>(setA: Set<T>, setB: Set<T>) {
1308+
if (setA.size !== setB.size) return false;
1309+
for (const val of setA) {
1310+
if (!setB.has(val)) return false;
1311+
}
1312+
return true;
1313+
}

frontend/javascripts/test/api/api_skeleton_latest.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ import { vi, describe, it, expect, beforeEach } from "vitest";
88
import type { Vector3 } from "viewer/constants";
99
import { enforceSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor";
1010

11-
// All the mocking is done in the helpers file, so it can be reused for both skeleton and volume API
1211
describe("API Skeleton", () => {
1312
beforeEach<WebknossosTestContext>(async (context) => {
14-
await setupWebknossosForTesting(context, "skeleton");
13+
await setupWebknossosForTesting(context, "skeleton", { dontDispatchWkReady: true });
1514
});
1615

1716
it<WebknossosTestContext>("getActiveNodeId should get the active node id", ({ api }) => {

frontend/javascripts/test/api/api_volume_latest.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from "../fixtures/volumetracing_server_objects";
88
import { AnnotationTool } from "viewer/model/accessors/tool_accessor";
99

10-
// All the mocking is done in the helpers file, so it can be reused for both skeleton and volume API
1110
describe("API Volume", () => {
1211
beforeEach<WebknossosTestContext>(async (context) => {
1312
await setupWebknossosForTesting(context, "volume");

frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { createTreeMapFromTreeArray } from "viewer/model/reducers/skeletontracin
1111
import { diffTrees } from "viewer/model/sagas/skeletontracing_saga";
1212
import { getNullableSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor";
1313
import { getServerVolumeTracings } from "viewer/model/accessors/volumetracing_accessor";
14-
import { sendRequestWithToken, addVersionNumbers } from "viewer/model/sagas/save_saga";
14+
import { addVersionNumbers } from "viewer/model/sagas/save_saga";
1515
import * as UpdateActions from "viewer/model/sagas/update_actions";
1616
import * as api from "admin/rest_api";
1717
import generateDummyTrees from "viewer/model/helpers/generate_dummy_trees";
@@ -174,7 +174,7 @@ describe("Annotation API (E2E)", () => {
174174
});
175175

176176
async function sendUpdateActions(explorational: APIAnnotation, queue: SaveQueueEntry[]) {
177-
return sendRequestWithToken(
177+
return api.sendSaveRequestWithToken(
178178
`${explorational.tracingStore.url}/tracings/annotation/${explorational.id}/update?token=`,
179179
{
180180
method: "POST",

frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import {
55
type APITracingStoreAnnotation,
66
} from "types/api_types";
77

8-
const TRACING_ID = "47e37793-d0be-4240-a371-87ce68561a13";
8+
const TRACING_ID = "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13";
99

1010
export const tracing: ServerSkeletonTracing = {
1111
typ: AnnotationLayerEnum.Skeleton,
12-
id: "47e37793-d0be-4240-a371-87ce68561a13",
12+
id: TRACING_ID,
1313
trees: [
1414
{
1515
treeId: 2,

frontend/javascripts/test/fixtures/tasktracing_server_objects.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type APITracingStoreAnnotation,
66
} from "types/api_types";
77

8-
const TRACING_ID = "e90133de-b2db-4912-8261-8b6f84f7edab";
8+
const TRACING_ID = "skeletonTracingId-e90133de-b2db-4912-8261-8b6f84f7edab";
99
export const tracing: ServerSkeletonTracing = {
1010
typ: "Skeleton",
1111
trees: [
@@ -42,7 +42,7 @@ export const tracing: ServerSkeletonTracing = {
4242
b: 0,
4343
a: 1,
4444
},
45-
name: "",
45+
name: "", // there is a test that asserts that empty names will be renamed automatically
4646
isVisible: true,
4747
createdTimestamp: 1528811979356,
4848
metadata: [],
@@ -65,27 +65,27 @@ export const tracing: ServerSkeletonTracing = {
6565
},
6666
additionalAxes: [],
6767
zoomLevel: 2,
68-
id: "e90133de-b2db-4912-8261-8b6f84f7edab",
68+
id: TRACING_ID,
6969
};
7070
export const annotation: APIAnnotation = {
71-
datasetId: "66f3c82966010034942e9740",
71+
datasetId: "datasetId-66f3c82966010034942e9740",
7272
modified: 1529066010230,
7373
state: "Active",
74-
id: "5b1fd1cf97000027049c67ee",
74+
id: "annotationId-5b1fd1cf97000027049c67ee",
7575
name: "",
7676
description: "",
7777
stats: {},
7878
typ: "Task",
7979
task: {
80-
id: "5b1fd1cb97000027049c67ec",
80+
id: "taskId-5b1fd1cb97000027049c67ec",
8181
projectName: "sampleProject",
8282
projectId: "dummy-project-id",
8383
team: "Connectomics department",
8484
type: {
8585
id: "5b1e45faa000009d00abc2c6",
8686
summary: "sampleTaskType",
8787
description: "Description",
88-
teamId: "5b1e45f9a00000a000abc2c3",
88+
teamId: "teamId-5b1e45f9a00000a000abc2c3",
8989
teamName: "Connectomics department",
9090
settings: {
9191
allowedModes: ["orthogonal", "oblique", "flight"],
@@ -98,7 +98,7 @@ export const annotation: APIAnnotation = {
9898
recommendedConfiguration: null,
9999
tracingType: "skeleton",
100100
},
101-
datasetId: "66f3c82966010034942e9740",
101+
datasetId: "datasetId-66f3c82966010034942e9740",
102102
datasetName: "ROI2017_wkw",
103103
neededExperience: {
104104
domain: "oxalis",
@@ -156,7 +156,7 @@ export const annotation: APIAnnotation = {
156156
tracingTime: null,
157157
tags: ["ROI2017_wkw", "skeleton"],
158158
owner: {
159-
id: "5b1e45faa00000a900abc2c5",
159+
id: "userId-5b1e45faa00000a900abc2c5",
160160
email: "sample@scm.io",
161161
firstName: "Sample",
162162
lastName: "User",
@@ -165,7 +165,7 @@ export const annotation: APIAnnotation = {
165165
isDatasetManager: true,
166166
teams: [
167167
{
168-
id: "5b1e45f9a00000a000abc2c3",
168+
id: "teamId-5b1e45f9a00000a000abc2c3",
169169
name: "Connectomics department",
170170
isTeamManager: true,
171171
},
@@ -176,7 +176,7 @@ export const annotation: APIAnnotation = {
176176
isLockedByOwner: false,
177177
teams: [
178178
{
179-
id: "5b1e45f9a00000a000abc2c3",
179+
id: "teamId-5b1e45f9a00000a000abc2c3",
180180
name: "Connectomics department",
181181
organization: "Connectomics department",
182182
},

frontend/javascripts/test/fixtures/volumetracing_object.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { AnnotationTool } from "viewer/model/accessors/tool_accessor";
33
import Constants from "viewer/constants";
44
import defaultState from "viewer/default_state";
55

6+
export const VOLUME_TRACING_ID = "volumeTracingId";
7+
68
const volumeTracing = {
79
type: "volume",
810
activeCellId: 0,
911
activeTool: AnnotationTool.MOVE,
1012
largestSegmentId: 0,
1113
contourList: [],
1214
lastLabelActions: [],
13-
tracingId: "tracingId",
15+
tracingId: VOLUME_TRACING_ID,
1416
};
1517
const notEmptyViewportRect = {
1618
top: 0,

frontend/javascripts/test/fixtures/volumetracing_server_objects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type APITracingStoreAnnotation,
66
} from "types/api_types";
77

8-
const TRACING_ID = "tracingId-1234";
8+
const TRACING_ID = "volumeTracingId-1234";
99
export const tracing: ServerVolumeTracing = {
1010
typ: "Volume",
1111
activeSegmentId: 10000,

frontend/javascripts/test/global_mocks.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,32 @@ vi.mock("viewer/model/helpers/shader_editor.ts", () => ({
112112
destroy: vi.fn(),
113113
},
114114
}));
115+
116+
vi.mock("antd", () => {
117+
return {
118+
Button: {},
119+
theme: {
120+
getDesignToken: () => ({ colorPrimary: "white" }),
121+
defaultAlgorithm: {},
122+
},
123+
Dropdown: {},
124+
message: {
125+
hide: vi.fn(),
126+
// These return a "hide function"
127+
show: vi.fn(() => () => {}),
128+
loading: vi.fn(() => () => {}),
129+
success: vi.fn(() => () => {}),
130+
},
131+
Modal: {
132+
confirm: vi.fn(),
133+
},
134+
Select: {
135+
Option: {},
136+
},
137+
Form: {
138+
Item: {},
139+
},
140+
};
141+
});
142+
143+
vi.mock("libs/render_independently", () => ({ default: vi.fn() }));

frontend/javascripts/test/helpers/apiHelpers.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,18 @@ import Model from "viewer/model";
2929
import UrlManager from "viewer/controller/url_manager";
3030

3131
import WebknossosApi from "viewer/api/api_loader";
32-
import { default as Store, startSaga } from "viewer/store";
32+
import { type SaveQueueEntry, default as Store, startSaga } from "viewer/store";
3333
import rootSaga from "viewer/model/sagas/root_saga";
3434
import { setStore, setModel } from "viewer/singletons";
3535
import { setupApi } from "viewer/api/internal_api";
3636
import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions";
3737
import Request, { type RequestOptions } from "libs/request";
3838
import { parseProtoAnnotation, parseProtoTracing } from "viewer/model/helpers/proto_helpers";
3939
import app from "app";
40+
import { sendSaveRequestWithToken } from "admin/rest_api";
41+
import { restartSagaAction, wkReadyAction } from "viewer/model/actions/actions";
42+
import { discardSaveQueuesAction } from "viewer/model/actions/save_actions";
43+
import { setActiveUserAction } from "viewer/model/actions/user_actions";
4044

4145
const TOKEN = "secure-token";
4246
const ANNOTATION_TYPE = "annotationTypeValue";
@@ -51,6 +55,7 @@ export interface WebknossosTestContext extends BaseTestContext {
5155
setSlowCompression: (enabled: boolean) => void;
5256
api: ApiInterface;
5357
tearDownPullQueues: () => void;
58+
receivedDataPerSaveRequest: Array<SaveQueueEntry[]>;
5459
}
5560

5661
// Create mock objects
@@ -67,6 +72,21 @@ vi.mock("libs/request", () => ({
6772
},
6873
}));
6974

75+
vi.mock("admin/rest_api.ts", async () => {
76+
const actual = await vi.importActual<typeof import("admin/rest_api.ts")>("admin/rest_api.ts");
77+
78+
const receivedDataPerSaveRequest: Array<SaveQueueEntry[]> = [];
79+
const mockedSendRequestWithToken = vi.fn((_, payload) => {
80+
receivedDataPerSaveRequest.push(payload.data);
81+
return Promise.resolve();
82+
});
83+
(mockedSendRequestWithToken as any).receivedDataPerSaveRequest = receivedDataPerSaveRequest;
84+
return {
85+
...actual,
86+
sendSaveRequestWithToken: mockedSendRequestWithToken,
87+
};
88+
});
89+
7090
vi.mock("libs/compute_bvh_async", () => ({
7191
computeBvhAsync: vi.fn().mockResolvedValue(undefined),
7292
}));
@@ -176,8 +196,15 @@ startSaga(rootSaga);
176196
export async function setupWebknossosForTesting(
177197
testContext: WebknossosTestContext,
178198
mode: keyof typeof modelData,
179-
apiVersion?: number,
199+
options?: { dontDispatchWkReady?: boolean },
180200
): Promise<void> {
201+
/*
202+
* This will execute model.fetch(...) and initialize the store with the tracing, etc.
203+
*/
204+
Store.dispatch(restartSagaAction());
205+
Store.dispatch(discardSaveQueuesAction());
206+
Store.dispatch(setActiveUserAction(dummyUser));
207+
181208
Store.dispatch(setActiveOrganizationAction(dummyOrga));
182209
UrlManager.initialState = {
183210
position: [1, 2, 3],
@@ -192,6 +219,9 @@ export async function setupWebknossosForTesting(
192219
Model.getAllLayers().map((layer) => {
193220
layer.pullQueue.destroy();
194221
});
222+
testContext.receivedDataPerSaveRequest = (
223+
sendSaveRequestWithToken as any
224+
).receivedDataPerSaveRequest;
195225

196226
const webknossos = new WebknossosApi(Model);
197227
const annotationFixture = modelData[mode].annotation;
@@ -221,8 +251,16 @@ export async function setupWebknossosForTesting(
221251
// Trigger the event ourselves, as the webKnossosController is not instantiated
222252
app.vent.emit("webknossos:ready");
223253

224-
const api = await webknossos.apiReady(apiVersion);
254+
const api = await webknossos.apiReady();
225255
testContext.api = api;
256+
257+
// Ensure the slow compression is disabled by default. Tests may change
258+
// this individually.
259+
testContext.setSlowCompression(false);
260+
if (!options?.dontDispatchWkReady) {
261+
// Dispatch the wkReadyAction, so the sagas are started
262+
Store.dispatch(wkReadyAction());
263+
}
226264
} catch (error) {
227265
console.error("model.fetch() failed", error);
228266
if (error instanceof Error) {

frontend/javascripts/test/libs/__snapshots__/nml.spec.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ exports[`NML > NML serializer should escape special characters and multilines 1`
66
<meta name="writerGitCommit" content="fc0ea6432ec7107e8f9b5b308ee0e90eae0e7b17" />
77
<meta name="timestamp" content="1494695001688" />
88
<meta name="annotationId" content="annotationId" />
9-
<meta name="taskId" content="5b1fd1cb97000027049c67ec" />
9+
<meta name="taskId" content="taskId-5b1fd1cb97000027049c67ec" />
1010
<parameters>
1111
<experiment datasetId="dummy-dataset-id" name="Loading" description="Multiline dataset&#xa;description&#xa;with special &amp;&apos;&lt;&gt;&quot; chars." organization="" wkUrl="http://localhost:9000" />
1212
<scale x="5" y="5" z="5" unit="nanometer" />
@@ -65,7 +65,7 @@ exports[`NML > NML serializer should produce correct NMLs 1`] = `
6565
<meta name="writerGitCommit" content="fc0ea6432ec7107e8f9b5b308ee0e90eae0e7b17" />
6666
<meta name="timestamp" content="1494695001688" />
6767
<meta name="annotationId" content="annotationId" />
68-
<meta name="taskId" content="5b1fd1cb97000027049c67ec" />
68+
<meta name="taskId" content="taskId-5b1fd1cb97000027049c67ec" />
6969
<parameters>
7070
<experiment datasetId="dummy-dataset-id" name="Loading" description="" organization="" wkUrl="http://localhost:9000" />
7171
<scale x="5" y="5" z="5" unit="nanometer" />
@@ -123,7 +123,7 @@ exports[`NML > NML serializer should produce correct NMLs with additional coordi
123123
<meta name="writerGitCommit" content="fc0ea6432ec7107e8f9b5b308ee0e90eae0e7b17" />
124124
<meta name="timestamp" content="1494695001688" />
125125
<meta name="annotationId" content="annotationId" />
126-
<meta name="taskId" content="5b1fd1cb97000027049c67ec" />
126+
<meta name="taskId" content="taskId-5b1fd1cb97000027049c67ec" />
127127
<parameters>
128128
<experiment datasetId="dummy-dataset-id" name="Loading" description="" organization="" wkUrl="http://localhost:9000" />
129129
<scale x="5" y="5" z="5" unit="nanometer" />

frontend/javascripts/test/libs/nml.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,9 @@ describe("NML", () => {
555555
});
556556

557557
it("Serialized nml should be correctly named", async () => {
558-
expect(getNmlName(initialState)).toBe("Test Dataset__5b1fd1cb97000027049c67ec____tionId.nml");
558+
expect(getNmlName(initialState)).toBe(
559+
"Test Dataset__taskId-5b1fd1cb97000027049c67ec____tionId.nml",
560+
);
559561

560562
const stateWithoutTask = { ...initialState, task: null };
561563

0 commit comments

Comments
 (0)