Skip to content
This repository was archived by the owner on Apr 18, 2024. It is now read-only.

Commit 6661a8a

Browse files
fix: DEV-3617: Memory leaks in Taxonomy + Repeater cases (#935)
* Reduce memory footprint by leveraging single-source store * Taxonomy lazy loading * Cleanup * Combine dynamic loading with lazyload * Remove unused code * Remove unused code * Add feature description * Properly detach/attach shared stores on reset * Cleanup * Add large taxonomy example * Tweaks
1 parent 5973d37 commit 6661a8a

File tree

22 files changed

+3945
-106
lines changed

22 files changed

+3945
-106
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"annotations": [
3+
{
4+
"result": [
5+
]
6+
}
7+
],
8+
"data": {
9+
"asin": "1384719342",
10+
"helpful": [
11+
13,
12+
14
13+
],
14+
"overall": 5,
15+
"reviewText": "What a waste of money and time!",
16+
"reviewTime": "03 16, 2013",
17+
"reviewerID": "A14VAT5EAX3D9S",
18+
"reviewerName": "Jake",
19+
"summary": "Jake",
20+
"unixReviewTime": 1363392000
21+
},
22+
"id": 1,
23+
"task_path": "../examples/sentiment_analysis/tasks.json"
24+
}

examples/taxonomy_large/config.xml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<View>
2+
<Header>Document ID: $document_id</Header>
3+
<Repeater indexFlag="{{idx}}" on="$images" mode="pagination">
4+
<View style="display: flex; margin-bottom: 10px;">
5+
<View style="margin-right: 10px; width: 20em">
6+
<Header value="Labels">Header</Header>
7+
<RectangleLabels name="labels_{{idx}}" toName="page_{{idx}}">
8+
<Label alias="title" value="Title"/>
9+
<Label alias="reference" value="Document reference"/>
10+
</RectangleLabels>
11+
<RectangleLabels name="taxonomy_{{idx}}" toName="page_{{idx}}">
12+
<Label alias="taxonomy" background="#B2CDFE" selectedColor="#74A7FE" value="Primary taxonomy">
13+
</Label>
14+
<Label alias="taxonomy_other" background="#F9AEC8" selectedColor="#FF7FAC" value="Other taxonomies">
15+
</Label>
16+
</RectangleLabels>
17+
<View>
18+
<Taxonomy name="categories_{{idx}}" perRegion="true" toName="page_{{idx}}" visibleWhen="region-selected" whenLabelValue="taxonomy" value="$categories" sharedStore="categories"/>
19+
<Taxonomy name="taxonomy_other_{{idx}}" perRegion="true" toName="page_{{idx}}" visibleWhen="region-selected" whenLabelValue="taxonomy_other" value="$other" sharedStore="taxonomies"/>
20+
</View>
21+
</View>
22+
<View style="width: 2048px">
23+
<Image maxHeight="auto" name="page_{{idx}}" value="$images[{{idx}}].url">
24+
</Image>
25+
</View>
26+
</View>
27+
</Repeater>
28+
</View>

examples/taxonomy_large/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import config from "./config.xml";
2+
import tasks from "./tasks.json";
3+
import annotation from "./annotations/0.json";
4+
5+
export const TaxonomyLarge = { config, tasks, annotation };

examples/taxonomy_large/tasks.json

Lines changed: 3497 additions & 0 deletions
Large diffs are not rendered by default.

src/LabelStudio.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isDefined } from "./utils/utilities";
1111
import { Hotkey } from "./core/Hotkey";
1212
import defaultOptions from './defaultOptions';
1313
import { destroy } from "mobx-state-tree";
14+
import { destroy as destroySharedStore } from './mixins/SharedChoiceStore/mixin';
1415

1516
configure({
1617
isolateGlobalState: true,
@@ -70,6 +71,7 @@ export class LabelStudio {
7071

7172
const destructor = () => {
7273
unmountComponentAtNode(rootElement);
74+
destroySharedStore();
7375
destroy(this.store);
7476
};
7577

src/core/Types.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function unionTag(arr) {
3939
function tagsTypes(arr) {
4040
const type = types.frozen(arr.map(val => val.toLowerCase()));
4141

42-
type.describe = ()=>`(${arr.join("|")})`;
42+
type.describe = () => `(${arr.join("|")})`;
4343
type.value = arr;
4444
return type;
4545
}
@@ -109,4 +109,16 @@ function getParentTagOfTypeString(node, str) {
109109
const oneOfTools = _oneOf(Registry.getTool, "Not expecting tool: ");
110110
const toolsArray = _mixedArray(oneOfTools);
111111

112-
export default { unionArray, allModelsTypes, unionTag, tagsTypes, isType, getParentOfTypeString, getParentTagOfTypeString, tagsArray, toolsArray };
112+
const Types = {
113+
unionArray,
114+
allModelsTypes,
115+
unionTag,
116+
tagsTypes,
117+
isType,
118+
getParentOfTypeString,
119+
getParentTagOfTypeString,
120+
tagsArray,
121+
toolsArray,
122+
};
123+
124+
export default Types;

src/env/development.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { ImageTools } from "../examples/image_tools";
4343
*/
4444
import { HTMLDocument } from "../examples/html_document";
4545
import { Taxonomy } from "../examples/taxonomy";
46+
import { TaxonomyLarge } from "../examples/taxonomy_large";
4647

4748
/**
4849
* RichText (HTML or plain text)
@@ -68,7 +69,7 @@ import { TimeSeriesSingle } from "../examples/timeseries_single";
6869
*/
6970
// import { AllTypes } from "../examples/all_types";
7071

71-
const data = VideoRectangles;
72+
const data = TaxonomyLarge;
7273

7374
function getData(task) {
7475
if (task && task.data) {

src/mixins/DynamicChildrenMixin.js

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import { flow, types } from "mobx-state-tree";
1+
import { getRoot, types } from "mobx-state-tree";
22
import ProcessAttrsMixin from "./ProcessAttrs";
33
import { parseValue } from "../utils/data";
44

5+
56
const DynamicChildrenMixin = types.model({
67
})
7-
.views(self => {
8-
return {
9-
get defaultChildType() {
10-
console.error("DynamicChildrenMixin needs to implement defaultChildType getter in views");
11-
return undefined;
12-
},
13-
};
14-
})
8+
.views(() => ({
9+
get defaultChildType() {
10+
console.error("DynamicChildrenMixin needs to implement defaultChildType getter in views");
11+
return undefined;
12+
},
13+
}))
1514
.actions(self => {
1615
const prepareDynamicChildrenData = (data) => data?.map?.(obj => ({
1716
type: self.defaultChildType,
1817
...obj,
1918
children: prepareDynamicChildrenData(obj.children),
2019
}));
20+
2121
const postprocessDynamicChildren = (children, store) => {
2222
children?.forEach(item => {
2323
postprocessDynamicChildren(item.children, store);
@@ -26,29 +26,45 @@ const DynamicChildrenMixin = types.model({
2626
};
2727

2828
return {
29+
updateWithDynamicChildren(data) {
30+
const list = (self.children ?? []).concat(prepareDynamicChildrenData(data));
31+
32+
self.children = list;
33+
},
34+
2935
updateValue(store) {
3036
// If we want to use resolveValue or another asynchronous method here
3137
// we may need to rewrite this, initRoot and the other related methods
3238
// (actually a lot of them) to work asynchronously as well
3339

34-
const valueFromTask = parseValue(self.value, store.task.dataObj);
40+
setTimeout(() => {
41+
self.updateDynamicChildren(store);
42+
});
43+
},
44+
45+
updateDynamicChildren(store) {
46+
if (self.locked !== true) {
47+
const valueFromTask = parseValue(self.value, store.task.dataObj);
3548

36-
if (!valueFromTask) return;
49+
if (!valueFromTask) return;
3750

38-
self.generateDynamicChildren(valueFromTask, store);
39-
if (self.annotation) self.needsUpdate?.();
51+
self.updateWithDynamicChildren(valueFromTask);
52+
// self.generateDynamicChildren(valueFromTask, store);
53+
if (self.annotation) self.needsUpdate?.();
54+
}
4055
},
4156

4257
generateDynamicChildren(data, store) {
43-
if (!self.children) {
44-
self.children = prepareDynamicChildrenData(data);
45-
} else {
46-
self.children.push(...prepareDynamicChildrenData(data));
47-
}
58+
if (self.children) {
59+
const children = self.children;
60+
const len = children.length;
61+
const start = len - data.length;
62+
const slice = children.slice(start, len);
4863

49-
if (self.children) postprocessDynamicChildren(self.children.slice(self.children.length - data.length,self.children.length), store);
64+
postprocessDynamicChildren(slice, store);
65+
}
5066
},
5167
};
5268
});
5369

54-
export default types.compose(ProcessAttrsMixin, DynamicChildrenMixin);
70+
export default types.compose(ProcessAttrsMixin, DynamicChildrenMixin);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { destroy, detach, types } from "mobx-state-tree";
2+
import { SharedStoreModel } from "./model";
3+
import { Stores } from "./mixin";
4+
5+
/**
6+
* StoreExtender injects into the AnnotationStore and holds every created SharedStore.
7+
*
8+
* Underlying tags that use SharedStoreMixin have access to methods of this mixin to add
9+
* their SharedStore instances.
10+
*/
11+
export const StoreExtender = types.model("StoreExtender", {
12+
sharedStores: types.optional(types.map(SharedStoreModel), {}),
13+
}).actions((self) => ({
14+
addSharedStore(store) {
15+
self.sharedStores.set(store.id, store);
16+
},
17+
beforeReset() {
18+
self.sharedStores.forEach((store) => {
19+
detach(store);
20+
});
21+
self.sharedStores.clear();
22+
},
23+
afterReset() {
24+
Stores.forEach((store) => {
25+
self.addSharedStore(store);
26+
});
27+
},
28+
beforeDestroy() {
29+
self.sharedStores.forEach((store) => {
30+
detach(store);
31+
destroy(store);
32+
});
33+
self.sharedStores.clear();
34+
},
35+
}));
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { types } from "mobx-state-tree";
2+
import Types from "../../core/Types";
3+
import { SharedStoreModel } from "./model";
4+
5+
/**
6+
* StoreIds and Stores act as a cache.
7+
*
8+
* The reason behind those is that we're creating a new store on the `preProcessSnapshot` when there's no
9+
* access to the State Tree. When the store is created, it's put into the cache and retrieved back in the
10+
* `afterCreate` hook of the model.
11+
*
12+
* StoreIds is just a map of existing store IDs to reference to during the `preProcessSnapshot`.
13+
*/
14+
export const Stores = new Map();
15+
const StoreIds = new Set();
16+
17+
/**
18+
* Defines the ID to group SharedStores by.
19+
*/
20+
const SharedStoreID = types.optional(types.maybeNull(types.string), null);
21+
22+
/**
23+
* Defines the Store model referenced from the Annotation Store
24+
*/
25+
const Store = types.optional(types.maybeNull(types.late(() => types.reference(SharedStoreModel))), null);
26+
27+
/**
28+
* SharedStoreMixin, when injected into the model, provides an AnnotationStore level shared storages to
29+
* reduce the memory footprint and computation time.
30+
*
31+
* It was specifically designed to be used with Repeater tag where the memory issues are the most sound.
32+
*
33+
* This mixin provedes a `sharedStore` property to the model which is a reference to the shared store.
34+
*
35+
* The concept behind it is that whenever a model is parsing a snapshot, children are subtracted from the
36+
* initial snapshot, and put into the newly created SharedStore.
37+
*
38+
* The store is then put into the cache and attached to the model in the `afterCreate` hook. Any subsequent
39+
* models lookup the store in the cache first and use its id instead of creating a new one.
40+
*
41+
* When the store is fullfilled with children, it's locked and cannot be modified anymore. The allows the model
42+
* not to process children anymore and just use the store.
43+
*
44+
* Shared Stores live on the AnnotationStore level meaning that even if the user switches between annotations or
45+
* create new ones, they will all use the same shared store decreasing the memory footprint and computation time.
46+
*/
47+
export const SharedStoreMixin = types.model("SharedStoreMixin", {
48+
sharedstore: SharedStoreID,
49+
store: Store,
50+
})
51+
.views((self) => ({
52+
get children() {
53+
return self.sharedChildren;
54+
},
55+
56+
get locked() {
57+
return self.store?.locked ?? false;
58+
},
59+
60+
set children(val) {
61+
self.store?.lock();
62+
self.store.setChildren(val);
63+
},
64+
65+
get sharedChildren() {
66+
return self.store.children ?? [];
67+
},
68+
69+
get storeId() {
70+
return self.sharedstore ?? self.name;
71+
},
72+
}))
73+
.actions(self => ({
74+
afterCreate() {
75+
if (!self.store) {
76+
const store = Stores.get(self.storeId);
77+
const annotationStore = Types.getParentOfTypeString(self, "AnnotationStore");
78+
79+
annotationStore.addSharedStore(store);
80+
StoreIds.add(self.storeId);
81+
self.store = self.storeId;
82+
}
83+
},
84+
}))
85+
.preProcessSnapshot((sn) => {
86+
const storeId = sn.sharedstore ?? sn.name;
87+
88+
if (StoreIds.has(storeId)) {
89+
sn.store = storeId;
90+
} else {
91+
Stores.set(storeId, SharedStoreModel.create({
92+
id: storeId,
93+
children: sn._children ?? sn.children ?? [],
94+
}));
95+
}
96+
97+
return sn;
98+
});
99+
100+
export const destroy = () => {
101+
console.log("destroying");
102+
Stores.clear();
103+
StoreIds.clear();
104+
};
105+

0 commit comments

Comments
 (0)