Skip to content

Commit 4e152bf

Browse files
committed
2 parents b8c1d6b + b1f6581 commit 4e152bf

File tree

33 files changed

+533
-404
lines changed

33 files changed

+533
-404
lines changed

docs/source/guide/saas.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,13 @@ Please note that our documentation and company pages, served via https://app.hum
6565

6666
Label Studio imposes rate limits on a per-Access Token basis. If a request exceeds the rate limit, a response with a 429 status code is returned. Clients are advised to pause and retry after a short delay in such instances.
6767

68-
| Path | Rate limit |
69-
|---------------------------|--------------------------------------------------------|
70-
| `/api/projects/*/import` | `1 request / 1 second` |
71-
| `/api/tasks/*/annotations`| `5 request / 1 second` |
72-
| `/api` | `10 requests / 1 second`<br/>`500 requests / 1 minute` |
68+
| Path | Rate limit |
69+
|---------------------------|------------------------------------------------------------|
70+
| `/api/projects/*/import` | `1 request / 1 second` |
71+
| `/api/tasks/*/annotations`| `5 request / 1 second` |
72+
| `/api` | Limits are set per user within an organization. The default limit is:<br/><br/>`15 requests / 1 second` per user |
73+
74+
7375

7476
## Other Operational Limits
7577

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""This module contains tests for database utility functions in core/utils/db.py"""
2+
import pytest
3+
from core.utils.db import batch_delete
4+
from django.db import transaction
5+
from users.models import User
6+
from users.tests.factories import UserFactory
7+
8+
9+
class TestBatchDelete:
10+
"""Test suite for the batch_delete utility function"""
11+
12+
@pytest.mark.django_db
13+
def test_batch_delete_empty_queryset(self):
14+
"""Test batch deletion with an empty queryset.
15+
16+
This test verifies that:
17+
- Function handles empty querysets gracefully
18+
- Returns 0 for total deleted items
19+
- No errors are raised
20+
"""
21+
# Setup: Empty queryset
22+
queryset = User.objects.filter(email='[email protected]')
23+
24+
# Action: Attempt to delete empty queryset
25+
total_deleted = batch_delete(queryset)
26+
27+
# Assert: No items were deleted
28+
assert total_deleted == 0
29+
30+
@pytest.mark.django_db
31+
def test_batch_delete_single_batch(self):
32+
"""Test batch deletion when all items fit in a single batch.
33+
34+
This test verifies that:
35+
- All items are deleted when count < batch_size
36+
- Items are actually removed from database
37+
"""
38+
# Setup: Create 5 users
39+
[UserFactory() for _ in range(5)]
40+
initial_count = User.objects.count()
41+
assert initial_count == 5 # Verify setup
42+
queryset = User.objects.all()
43+
44+
# Action: Delete with batch size larger than number of items
45+
batch_delete(queryset, batch_size=10)
46+
47+
# Assert: All users were deleted
48+
assert User.objects.count() == 0
49+
50+
@pytest.mark.django_db
51+
def test_batch_delete_multiple_batches(self):
52+
"""Test batch deletion when items span multiple batches.
53+
54+
This test verifies that:
55+
- All items are deleted when count > batch_size
56+
- Items are deleted in correct batches
57+
- All items are removed from database
58+
"""
59+
# Setup: Create 25 users (will require 3 batches with batch_size=10)
60+
[UserFactory() for _ in range(25)]
61+
initial_count = User.objects.count()
62+
assert initial_count == 25 # Verify setup
63+
queryset = User.objects.all()
64+
65+
# Action: Delete with batch size smaller than number of items
66+
batch_delete(queryset, batch_size=10)
67+
68+
# Assert: All users were deleted
69+
assert User.objects.count() == 0
70+
71+
@pytest.mark.django_db
72+
def test_batch_delete_with_transaction(self):
73+
"""Test batch deletion within a transaction.
74+
75+
This test verifies that:
76+
- Batch deletion works correctly inside a transaction
77+
- Changes are committed properly
78+
- No errors occur with nested transactions
79+
"""
80+
# Setup: Create 15 users
81+
[UserFactory() for _ in range(15)]
82+
initial_count = User.objects.count()
83+
assert initial_count == 15 # Verify setup
84+
queryset = User.objects.all()
85+
86+
# Action: Delete within a transaction
87+
with transaction.atomic():
88+
batch_delete(queryset, batch_size=10)
89+
90+
# Assert: All users were deleted
91+
assert User.objects.count() == 0
92+
93+
@pytest.mark.django_db
94+
def test_batch_delete_exact_batch_size(self):
95+
"""Test batch deletion when item count matches batch size exactly.
96+
97+
This test verifies that:
98+
- All items are deleted when count == batch_size
99+
- No extra queries are made after the last batch
100+
"""
101+
# Setup: Create exactly 10 users
102+
[UserFactory() for _ in range(10)]
103+
initial_count = User.objects.count()
104+
assert initial_count == 10 # Verify setup
105+
queryset = User.objects.all()
106+
107+
# Action: Delete with batch size equal to number of items
108+
batch_delete(queryset, batch_size=10)
109+
110+
# Assert: All users were deleted
111+
assert User.objects.count() == 0

label_studio/core/utils/db.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import itertools
12
import logging
23
import time
34
from typing import Optional, TypeVar
@@ -71,3 +72,49 @@ def batch_update_with_retry(queryset, batch_size=500, max_retries=3, **update_fi
7172
else:
7273
logger.error(f'Failed to update batch after {max_retries} retries. ' f'Batch: {i}-{i+len(batch_ids)}')
7374
raise last_error
75+
76+
77+
def batch_delete(queryset, batch_size=500):
78+
"""
79+
Delete objects in batches to minimize memory usage and transaction size.
80+
81+
Args:
82+
queryset: The queryset to delete
83+
batch_size: Number of objects to delete in each batch
84+
85+
Returns:
86+
int: Total number of deleted objects
87+
"""
88+
total_deleted = 0
89+
90+
# Create a database cursor that yields primary keys without loading all into memory
91+
# The iterator position is maintained between calls to islice
92+
# Example: if queryset has 1500 records and batch_size=500:
93+
# - First iteration will get records 1-500
94+
# - Second iteration will get records 501-1000
95+
# - Third iteration will get records 1001-1500
96+
# - Fourth iteration will get empty list (no more records)
97+
pks_to_delete = queryset.values_list('pk', flat=True).iterator(chunk_size=batch_size)
98+
99+
# Delete in batches
100+
while True:
101+
# Get the next batch of primary keys from where the iterator left off
102+
# islice advances the iterator's position after taking batch_size items
103+
batch_iterator = itertools.islice(pks_to_delete, batch_size)
104+
105+
# Convert the slice iterator to a list we can use
106+
# This only loads batch_size items into memory at a time
107+
batch = list(batch_iterator)
108+
109+
# If no more items to process, we're done
110+
# This happens when the iterator is exhausted
111+
if not batch:
112+
break
113+
114+
# Delete the batch in a transaction
115+
with transaction.atomic():
116+
# Delete all objects whose primary keys are in this batch
117+
deleted = queryset.model.objects.filter(pk__in=batch).delete()[0]
118+
total_deleted += deleted
119+
120+
return total_deleted

label_studio/feature_flags.json

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3149,7 +3149,7 @@
31493149
},
31503150
"fflag_feat_utc_161_finished_task_number_08072025_short": {
31513151
"key": "fflag_feat_utc_161_finished_task_number_08072025_short",
3152-
"on": false,
3152+
"on": true,
31533153
"prerequisites": [],
31543154
"targets": [],
31553155
"contextTargets": [],
@@ -3171,7 +3171,7 @@
31713171
"trackEvents": false,
31723172
"trackEventsFallthrough": false,
31733173
"debugEventsUntilDate": null,
3174-
"version": 2,
3174+
"version": 3,
31753175
"deleted": false
31763176
},
31773177
"fflag_feat_utc_46_session_timeout_policy": {
@@ -3903,6 +3903,33 @@
39033903
"version": 3,
39043904
"deleted": false
39053905
},
3906+
"fflag_fix_back_plt_825_rate_limiter_debug_14072025_short": {
3907+
"key": "fflag_fix_back_plt_825_rate_limiter_debug_14072025_short",
3908+
"on": false,
3909+
"prerequisites": [],
3910+
"targets": [],
3911+
"contextTargets": [],
3912+
"rules": [],
3913+
"fallthrough": {
3914+
"variation": 0
3915+
},
3916+
"offVariation": 1,
3917+
"variations": [
3918+
true,
3919+
false
3920+
],
3921+
"clientSideAvailability": {
3922+
"usingMobileKey": false,
3923+
"usingEnvironmentId": false
3924+
},
3925+
"clientSide": false,
3926+
"salt": "6c4552af705d4da7b6c4112da50166d6",
3927+
"trackEvents": false,
3928+
"trackEventsFallthrough": false,
3929+
"debugEventsUntilDate": null,
3930+
"version": 2,
3931+
"deleted": false
3932+
},
39063933
"fflag_fix_bros_95_drafts_from_other_users": {
39073934
"key": "fflag_fix_bros_95_drafts_from_other_users",
39083935
"on": true,

label_studio/tasks/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
string_is_url,
2424
temporary_disconnect_list_signal,
2525
)
26-
from core.utils.db import fast_first
26+
from core.utils.db import batch_delete, fast_first
2727
from core.utils.params import get_env
2828
from data_import.models import FileUpload
2929
from data_manager.managers import PreparedTaskManager, TaskManager
@@ -535,15 +535,15 @@ def save(self, *args, update_fields=None, **kwargs):
535535
@staticmethod
536536
def delete_tasks_without_signals(queryset):
537537
"""
538-
Delete Tasks queryset with switched off signals
538+
Delete Tasks queryset with switched off signals in batches to minimize memory usage
539539
:param queryset: Tasks queryset
540540
"""
541541
signals = [
542542
(post_delete, update_all_task_states_after_deleting_task, Task),
543543
(pre_delete, remove_data_columns, Task),
544544
]
545545
with temporary_disconnect_list_signal(signals):
546-
queryset.delete()
546+
return batch_delete(queryset, batch_size=500)
547547

548548
@staticmethod
549549
def delete_tasks_without_signals_from_task_ids(task_ids):

web/apps/labelstudio/src/pages/CreateProject/Import/Import.jsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ export const ImportPage = ({
409409
</dl>
410410
<div className="tips">
411411
<b>Important:</b>
412-
<ul className="mt-2 ml-4 list-disc font-regular">
412+
<ul className="mt-2 ml-4 list-disc font-normal">
413413
<li>
414414
We recommend{" "}
415415
<a
@@ -467,7 +467,7 @@ export const ImportPage = ({
467467
<td>
468468
<div className="flex items-center gap-2">
469469
{sample.title}
470-
<Badge variant="info" className="h-5 text-body-smaller rounded-sm">
470+
<Badge variant="info" className="h-5 text-xs rounded-sm">
471471
Sample
472472
</Badge>
473473
</div>
@@ -525,7 +525,7 @@ export const ImportPage = ({
525525
<Spinner className="h-6 w-6" />
526526
</div>
527527
) : sampleConfig.isError ? (
528-
<div className="w-[calc(100%-24px)] text-title-medium text-negative-content bg-negative-background border m-3 rounded-md border-negative-border-subtle p-4">
528+
<div className="w-[calc(100%-24px)] text-lg text-negative-content bg-negative-background border m-3 rounded-md border-negative-border-subtle p-4">
529529
Something went wrong, the sample data could not be loaded.
530530
</div>
531531
) : null}

web/apps/labelstudio/src/pages/Home/HomePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export const HomePage: Page = () => {
109109
data && data?.count > 0 ? (
110110
<>
111111
Recent Projects{" "}
112-
<a href="/projects" className="text-title-medium font-regular hover:underline">
112+
<a href="/projects" className="text-lg font-normal hover:underline">
113113
View All
114114
</a>
115115
</>
@@ -210,7 +210,7 @@ function ProjectSimpleCard({
210210
>
211211
<div className="flex flex-col gap-1">
212212
<span className="text-neutral-content">{project.title}</span>
213-
<div className="text-neutral-content-subtler text-body-small">
213+
<div className="text-neutral-content-subtler text-sm">
214214
{finished} of {total} Tasks ({total > 0 ? Math.round((finished / total) * 100) : 0}%)
215215
</div>
216216
</div>

web/apps/playground/src/components/PlaygroundApp/TopBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const TopBar = memo(
7171
return (
7272
<div className="flex items-center h-10 px-tight text-heading-medium justify-between select-none border-b border-neutral-border">
7373
<div className="flex items-center gap-2">
74-
<span className="font-semibold tracking-dense text-body-medium">
74+
<span className="font-semibold tracking-tight text-body-medium">
7575
Label Studio <span className="text-accent-persimmon-base">Playground</span>
7676
</span>
7777
</div>

web/libs/datamanager/src/components/DataGroups/TextDataGroup.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const TextDataGroup = ({ value, hasImage }) => {
2727
style={style}
2828
title={output}
2929
className={clsx("p-tight", {
30-
"text-wrap leading-body-medium": !hasImage,
30+
"text-wrap leading-normal": !hasImage,
3131
"text-nowrap": hasImage,
3232
})}
3333
>

0 commit comments

Comments
 (0)