Skip to content

Commit 93d03c2

Browse files
[PLT-0] Add safe staging embedding cleanup
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6a9a8e3 commit 93d03c2

5 files changed

Lines changed: 545 additions & 2 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Staging Embedding Cleanup
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
dry_run:
7+
description: >-
8+
Dry-run first. Set false only after confirming both Labelbox Python
9+
SDK Staging and LBox Develop are quiet.
10+
required: true
11+
type: boolean
12+
default: true
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
cleanup:
19+
runs-on: ubuntu-latest
20+
env:
21+
LABELBOX_TEST_API_KEY: ${{ secrets.STAGING_API_KEY_ORG_CMOI3PQ7801GM070D4TPG7FMH }}
22+
steps:
23+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
24+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
25+
with:
26+
python-version: "3.11"
27+
cache: pip
28+
- name: Install SDK from this repository
29+
working-directory: libs/labelbox
30+
run: python -m pip install .
31+
- name: List or clean leaked staging embeddings
32+
working-directory: libs/labelbox
33+
run: >-
34+
python -m tests.scripts.cleanup_staging_embeddings
35+
--dry-run "${{ inputs.dry_run }}"

libs/labelbox/tests/conftest.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
from labelbox.schema.ontology import Ontology
3333
from labelbox.schema.project import Project
3434
from labelbox.schema.quality_mode import QualityMode
35+
from tests.embedding_cleanup import (
36+
build_embedding_name,
37+
create_embedding_with_heal,
38+
)
3539

3640
# Must be a stable, deterministic JPEG: several tests assert byte-equality
3741
# between the source and the server-rehosted copy, so a random image service
@@ -1128,9 +1132,14 @@ def configured_project_with_complex_ontology(
11281132

11291133
@pytest.fixture
11301134
def embedding(client: Client, environ):
1131-
uuid_str = uuid.uuid4().hex
11321135
time.sleep(randint(1, 5))
1133-
embedding = client.create_embedding(f"sdk-int-{uuid_str}", 8)
1136+
embedding = create_embedding_with_heal(
1137+
create_embedding=lambda: client.create_embedding(
1138+
build_embedding_name(time.time()), 8
1139+
),
1140+
list_embeddings=client.get_embeddings,
1141+
delete_embedding=lambda stale_embedding: stale_embedding.delete(),
1142+
)
11341143
yield embedding
11351144

11361145
embedding.delete()
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import os
2+
import re
3+
import time
4+
import uuid
5+
from random import uniform
6+
from typing import Any, Callable, Iterable, List, Optional
7+
8+
from lbox.exceptions import LabelboxError
9+
10+
EMBEDDING_NAME_PREFIX_V2 = "sdk-int-ci-v2-"
11+
EMBEDDING_CAP_ERROR_SNIPPET = "Max limit of custom embeddings"
12+
EMBEDDING_STALE_TTL_SECONDS = 12 * 3600
13+
LEGACY_NAME_RE = re.compile(r"^sdk-int-[0-9a-f]{32}$")
14+
15+
_V2_NAME_RE = re.compile(
16+
rf"^{re.escape(EMBEDDING_NAME_PREFIX_V2)}(\d+)-[0-9a-f]{{10}}$"
17+
)
18+
_MAX_CREATE_ATTEMPTS = 3
19+
20+
21+
def build_embedding_name(now: float) -> str:
22+
return f"{EMBEDDING_NAME_PREFIX_V2}{int(now)}-{uuid.uuid4().hex[:10]}"
23+
24+
25+
def parse_embedding_created_at(name: str) -> Optional[int]:
26+
match = _V2_NAME_RE.fullmatch(name)
27+
return int(match.group(1)) if match is not None else None
28+
29+
30+
def select_stale_embeddings(embeddings: Iterable[Any], now: float) -> List[Any]:
31+
stale_embeddings = []
32+
for embedding in embeddings:
33+
created_at = parse_embedding_created_at(embedding.name)
34+
if (
35+
embedding.custom
36+
and created_at is not None
37+
and now - created_at > EMBEDDING_STALE_TTL_SECONDS
38+
):
39+
stale_embeddings.append(embedding)
40+
return stale_embeddings
41+
42+
43+
def is_embedding_cap_error(error: LabelboxError) -> bool:
44+
return EMBEDDING_CAP_ERROR_SNIPPET in str(error)
45+
46+
47+
def create_embedding_with_heal(
48+
*,
49+
create_embedding: Callable[[], Any],
50+
list_embeddings: Callable[[], Iterable[Any]],
51+
delete_embedding: Callable[[Any], None],
52+
sleep: Callable[[float], None] = time.sleep,
53+
now: Callable[[], float] = time.time,
54+
retry_delay: Callable[[float, float], float] = uniform,
55+
print_fn: Callable[[str], None] = print,
56+
) -> Any:
57+
last_swept_count = None
58+
59+
for attempt in range(1, _MAX_CREATE_ATTEMPTS + 1):
60+
try:
61+
return create_embedding()
62+
except LabelboxError as error:
63+
if not is_embedding_cap_error(error):
64+
raise
65+
66+
if attempt == _MAX_CREATE_ATTEMPTS:
67+
if last_swept_count == 0:
68+
print_fn(
69+
"[embedding-fixture-heal] no stale embeddings were "
70+
"swept; the cap appears held by live fixtures and/or "
71+
"legacy/foreign names that automated healing "
72+
"deliberately does not touch"
73+
)
74+
raise
75+
76+
stale_embeddings = select_stale_embeddings(list_embeddings(), now())
77+
for embedding in stale_embeddings:
78+
try:
79+
delete_embedding(embedding)
80+
except LabelboxError:
81+
# Another worker may have deleted the same stale embedding.
82+
pass
83+
84+
last_swept_count = len(stale_embeddings)
85+
sample = ",".join(
86+
embedding.id for embedding in stale_embeddings[:3]
87+
)
88+
print_fn(
89+
"[embedding-fixture-heal] "
90+
f"run={os.getenv('GITHUB_RUN_ID', '-')} "
91+
f"worker={os.getenv('PYTEST_XDIST_WORKER', '-')} "
92+
f"attempt={attempt} cap_hit=1 "
93+
f"swept={last_swept_count} sample={sample or '-'}"
94+
)
95+
sleep(retry_delay(2, 8))
96+
97+
raise AssertionError("unreachable")
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import argparse
2+
import os
3+
from typing import Any, Callable, Iterable, List, Optional, Sequence
4+
from urllib.parse import urlparse
5+
6+
from lbox.exceptions import LabelboxError
7+
8+
from labelbox import Client
9+
from tests.embedding_cleanup import (
10+
LEGACY_NAME_RE,
11+
select_stale_embeddings,
12+
)
13+
14+
STAGING_GRAPHQL_ENDPOINT = "https://api.lb-stage.xyz/graphql"
15+
STAGING_REST_ENDPOINT = "https://api.lb-stage.xyz/api/v1"
16+
STAGING_REST_HOST = "api.lb-stage.xyz"
17+
18+
19+
def _parse_boolean(value: str) -> bool:
20+
normalized = value.strip().lower()
21+
if normalized == "true":
22+
return True
23+
if normalized == "false":
24+
return False
25+
raise argparse.ArgumentTypeError("expected 'true' or 'false'")
26+
27+
28+
def assert_staging_rest_endpoint(client: Any) -> None:
29+
effective_host = urlparse(client.rest_endpoint).hostname
30+
if effective_host != STAGING_REST_HOST:
31+
raise RuntimeError(
32+
"Refusing to inspect embeddings: the effective REST endpoint "
33+
f"host is {effective_host!r}, expected {STAGING_REST_HOST!r}"
34+
)
35+
36+
37+
def create_staging_client(
38+
*,
39+
api_key: Optional[str] = None,
40+
client_factory: Callable[..., Any] = Client,
41+
) -> Any:
42+
effective_api_key = api_key or os.environ.get("LABELBOX_TEST_API_KEY")
43+
if not effective_api_key:
44+
raise RuntimeError("LABELBOX_TEST_API_KEY is required")
45+
46+
client = client_factory(
47+
api_key=effective_api_key,
48+
endpoint=STAGING_GRAPHQL_ENDPOINT,
49+
rest_endpoint=STAGING_REST_ENDPOINT,
50+
)
51+
assert_staging_rest_endpoint(client)
52+
return client
53+
54+
55+
def select_cleanup_candidates(
56+
embeddings: Iterable[Any], now: float
57+
) -> List[Any]:
58+
embeddings = list(embeddings)
59+
stale_v2_ids = {
60+
embedding.id for embedding in select_stale_embeddings(embeddings, now)
61+
}
62+
return [
63+
embedding
64+
for embedding in embeddings
65+
if embedding.custom
66+
and (
67+
LEGACY_NAME_RE.fullmatch(embedding.name) is not None
68+
or embedding.id in stale_v2_ids
69+
)
70+
]
71+
72+
73+
def run_cleanup(client: Any, *, dry_run: bool, now: float) -> int:
74+
# Validate the effective endpoint immediately before the first API read.
75+
assert_staging_rest_endpoint(client)
76+
print(
77+
"WARNING: this cleanup cannot detect active owners. Confirm both "
78+
"Labelbox Python SDK Staging and LBox Develop are quiet, and run "
79+
"dry-run first."
80+
)
81+
candidates = select_cleanup_candidates(client.get_embeddings(), now)
82+
83+
print(f"Embedding cleanup candidates ({len(candidates)}):")
84+
for embedding in candidates:
85+
print(f"candidate id={embedding.id} name={embedding.name}")
86+
87+
if dry_run:
88+
print(
89+
"[embedding-cleanup] "
90+
f"run={os.getenv('GITHUB_RUN_ID', '-')} dry_run=1 "
91+
f"candidates={len(candidates)} deleted=0 failed=0"
92+
)
93+
return 0
94+
95+
deleted = 0
96+
failed = []
97+
for embedding in candidates:
98+
try:
99+
embedding.delete()
100+
deleted += 1
101+
print(f"deleted id={embedding.id} name={embedding.name}")
102+
except LabelboxError as error:
103+
failed.append(embedding.id)
104+
print(
105+
f"failed id={embedding.id} name={embedding.name} error={error}"
106+
)
107+
108+
print(
109+
"[embedding-cleanup] "
110+
f"run={os.getenv('GITHUB_RUN_ID', '-')} dry_run=0 "
111+
f"candidates={len(candidates)} deleted={deleted} "
112+
f"failed={len(failed)}"
113+
)
114+
return 1 if failed else 0
115+
116+
117+
def main(argv: Optional[Sequence[str]] = None) -> int:
118+
parser = argparse.ArgumentParser(
119+
description=(
120+
"Clean leaked custom embeddings from the shared staging org. "
121+
"Confirm Labelbox Python SDK Staging and LBox Develop are quiet "
122+
"and run dry-run first."
123+
)
124+
)
125+
parser.add_argument(
126+
"--dry-run",
127+
type=_parse_boolean,
128+
default=True,
129+
help="true (default) lists only; false deletes every candidate",
130+
)
131+
args = parser.parse_args(argv)
132+
133+
import time
134+
135+
return run_cleanup(
136+
create_staging_client(),
137+
dry_run=args.dry_run,
138+
now=time.time(),
139+
)
140+
141+
142+
if __name__ == "__main__":
143+
raise SystemExit(main())

0 commit comments

Comments
 (0)