From 5369f1f917a97b1bb303116ea70a582dc8a1437f Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 17:43:32 +0200 Subject: [PATCH 1/9] feat(entity-linkage): surface linkage run progress (STIT-740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk linkage pass runs for over an hour at production scale, but the status endpoint only reported a coarse "running" state — progress counters lived as locals inside link_all and never reached the polled JobRecord. Surface in-flight progress so the Entity Linkage page can show how far a run has gotten: - add a LinkProgress model (scanned/total/created/skipped/failed + updated_at) - carry progress on JobRecord; RunThunk now receives the record so the run body can update it live - fetch the resource total once per run for the "X of Y" denominator - emit progress from link_all every 100 resources plus a final snapshot - render a progress bar, counts, last-updated, and expected-duration copy on the Entity Linkage page (surfaced automatically by the existing 2s poll) Progress state remains in-memory per worker (pre-existing limitation). Co-Authored-By: Claude Opus 4.8 --- .../src/stitch/entity_linkage/client.py | 12 +++ .../src/stitch/entity_linkage/entities.py | 18 ++++ .../src/stitch/entity_linkage/jobs.py | 5 +- .../src/stitch/entity_linkage/matching.py | 42 ++++++++- .../src/stitch/entity_linkage/routers/link.py | 3 +- deployments/entity-linkage/tests/test_jobs.py | 31 ++++++- .../entity-linkage/tests/test_link_api.py | 15 +++- .../entity-linkage/tests/test_matching.py | 39 ++++++++ .../src/pages/EntityLinkagePage.jsx | 89 ++++++++++++++++++- .../src/pages/EntityLinkagePage.test.jsx | 51 +++++++++++ 10 files changed, 294 insertions(+), 11 deletions(-) diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/client.py b/deployments/entity-linkage/src/stitch/entity_linkage/client.py index fb163927..f7c78ae4 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/client.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/client.py @@ -75,6 +75,18 @@ async def iter_oil_gas_fields( ): yield self._to_candidate(item) + async def get_oil_gas_fields_total(self) -> int | None: + """Total resource count, for use as a linkage-progress denominator. + + Fetches a single-item page purely to read ``total_count``; the streaming + iterator used by the pass itself discards that field. Returns ``None`` if + the payload omits an integer count, so progress can still report a + numerator without a denominator. + """ + payload = await self._client.list_oil_gas_fields_page(page=1, page_size=1) + total = payload.get("total_count") + return total if isinstance(total, int) else None + async def list_merge_candidates(self) -> list[dict[str, Any]]: return await self._client.list_merge_candidates() diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/entities.py b/deployments/entity-linkage/src/stitch/entity_linkage/entities.py index 085dad34..e2b7c5d9 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/entities.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/entities.py @@ -99,6 +99,24 @@ class BulkLinkResponse(BaseModel): resources_failed: int = 0 +class LinkProgress(BaseModel): + """In-flight progress of a running linkage pass. + + Written onto the live job record as the pass streams resources, so a poller + can see how far along a multi-hour run is rather than only "running". Field + names mirror :class:`BulkLinkResponse` so the running view and the final + result read consistently. ``total_resources`` is ``None`` when the + denominator could not be fetched; percent is derived by the caller. + """ + + resources_scanned: int + total_resources: int | None + merge_candidates_created: int + merge_candidates_skipped: int + resources_failed: int + updated_at: datetime + + class PaginationParams(BaseModel): page: int = Field(1, ge=1) page_size: int = Field(50, ge=1, le=200) diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py b/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py index 6ecdf534..643ea825 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py @@ -24,7 +24,7 @@ logger = logging.getLogger("stitch.entity_linkage") -RunThunk = Callable[[], Awaitable[BaseModel]] +RunThunk = Callable[["JobRecord"], Awaitable[BaseModel]] class JobState(str, Enum): @@ -39,6 +39,7 @@ class JobRecord(BaseModel): params: SerializeAsAny[BaseModel] started_at: datetime finished_at: datetime | None = None + progress: SerializeAsAny[BaseModel] | None = None result: SerializeAsAny[BaseModel] | None = None error: str | None = None @@ -81,7 +82,7 @@ async def start(self, params: BaseModel, run: RunThunk) -> JobRecord: async def _run(self, record: JobRecord, run: RunThunk) -> None: try: - record.result = await run() + record.result = await run(record) record.state = JobState.succeeded except Exception as exc: logger.exception("Linkage run %s failed", record.job_id) diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/matching.py b/deployments/entity-linkage/src/stitch/entity_linkage/matching.py index 27b32b59..53c5968e 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/matching.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/matching.py @@ -21,13 +21,15 @@ from __future__ import annotations import logging -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from datetime import UTC, datetime import httpx from stitch.entity_linkage.client import StitchApiClient from stitch.entity_linkage.entities import ( BulkLinkResponse, + LinkProgress, ResourceLinkResult, normalize_country, normalize_name, @@ -36,6 +38,11 @@ logger = logging.getLogger(__name__) +# How often the bulk pass publishes a progress snapshot, in resources scanned. +# Small enough that a 2s poller sees frequent movement, large enough to avoid +# building a progress model on every one of hundreds of thousands of resources. +PROGRESS_UPDATE_EVERY = 100 + # A 4xx from create-merge-candidate is an expected, non-fatal outcome during a # run: the API rejects a duplicate fingerprint (a candidate already exists) or a # resource that has already been merged. We skip those rather than aborting. @@ -171,17 +178,31 @@ async def link_all( apply_merges: bool, page_size: int, initiated_by: str, + on_progress: Callable[[LinkProgress], None] | None = None, ) -> BulkLinkResponse: """Run the bounded matcher over every resource, streaming ids page by page. Groups are de-duplicated by fingerprint across the run, so each block is submitted at most once even though every member rediscovers it. Members of an already-formed block are skipped without re-searching. + + ``on_progress``, when supplied, is called with a :class:`LinkProgress` + snapshot periodically (every ``PROGRESS_UPDATE_EVERY`` resources) and once + more at the end, so a poller can track a long run's advance. """ # Only needed when we will actually POST; skip the (currently unpaginated) # candidate-list fetch entirely on a dry run. known_existing = await _existing_fingerprints(client) if apply_merges else None + # Denominator for progress; None if the count is unavailable. A failure here + # must not abort the pass, so fall back to an unknown total. + total_resources: int | None + try: + total_resources = await client.get_oil_gas_fields_total() + except (StitchAPIError, httpx.HTTPError, OSError) as exc: + logger.warning("Could not fetch resource total for progress: %s", exc) + total_resources = None + groups_by_fingerprint: dict[str, list[int]] = {} processed_ids: set[int] = set() resources_scanned = 0 @@ -189,8 +210,24 @@ async def link_all( skipped = 0 failed = 0 + def emit_progress() -> None: + if on_progress is None: + return + on_progress( + LinkProgress( + resources_scanned=resources_scanned, + total_resources=total_resources, + merge_candidates_created=created, + merge_candidates_skipped=skipped, + resources_failed=failed, + updated_at=datetime.now(UTC), + ) + ) + async for candidate in client.iter_oil_gas_fields(page_size=page_size): resources_scanned += 1 + if resources_scanned % PROGRESS_UPDATE_EVERY == 0: + emit_progress() if candidate.id in processed_ids: continue @@ -232,6 +269,9 @@ async def link_all( elif was_skipped: skipped += 1 + # Final snapshot so the last poll before completion reflects the exact totals. + emit_progress() + return BulkLinkResponse( initiated_by=initiated_by, apply_merges=apply_merges, diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/routers/link.py b/deployments/entity-linkage/src/stitch/entity_linkage/routers/link.py index 5285444c..b29b5444 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/routers/link.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/routers/link.py @@ -83,13 +83,14 @@ async def start_link_all( """ initiated_by = user_label(auth_context.user) - async def run() -> BulkLinkResponse: + async def run(record: JobRecord) -> BulkLinkResponse: async with StitchApiClient() as client: return await matching.link_all( client, apply_merges=request.apply_merges, page_size=request.page_size, initiated_by=initiated_by, + on_progress=lambda progress: setattr(record, "progress", progress), ) try: diff --git a/deployments/entity-linkage/tests/test_jobs.py b/deployments/entity-linkage/tests/test_jobs.py index 154cba58..617c0f3b 100644 --- a/deployments/entity-linkage/tests/test_jobs.py +++ b/deployments/entity-linkage/tests/test_jobs.py @@ -7,6 +7,7 @@ from stitch.entity_linkage.jobs import ( JobAlreadyRunningError, + JobRecord, JobState, get_job_manager, reset_manager, @@ -21,6 +22,10 @@ class _Result(BaseModel): doubled: int +class _Progress(BaseModel): + scanned: int + + @pytest.fixture(autouse=True) def _reset(): reset_manager() @@ -42,7 +47,7 @@ def test_run_thunk_success_records_result() -> None: async def scenario() -> None: mgr = get_job_manager() - async def run() -> _Result: + async def run(_record: JobRecord) -> _Result: return _Result(doubled=6) record = await mgr.start(_Params(n=3), run) @@ -63,7 +68,7 @@ def test_run_thunk_failure_records_error() -> None: async def scenario() -> None: mgr = get_job_manager() - async def run() -> _Result: + async def run(_record: JobRecord) -> _Result: raise RuntimeError("kaboom") await mgr.start(_Params(), run) @@ -82,7 +87,7 @@ def test_manager_rejects_concurrent_start() -> None: async def scenario() -> None: mgr = get_job_manager() - async def slow() -> _Result: + async def slow(_record: JobRecord) -> _Result: await asyncio.sleep(0.5) return _Result(doubled=0) @@ -91,3 +96,23 @@ async def slow() -> _Result: await mgr.start(_Params(), slow) asyncio.run(scenario()) + + +def test_run_thunk_can_write_progress_onto_record() -> None: + async def scenario() -> None: + mgr = get_job_manager() + + async def run(record: JobRecord) -> _Result: + # The run body writes progress onto the live record; a poller reading + # mgr.current() must see it while the run is still in flight. + record.progress = _Progress(scanned=42) + return _Result(doubled=0) + + await mgr.start(_Params(), run) + for _ in range(200): + if mgr.current().state != JobState.running: + break + await asyncio.sleep(0.01) + assert mgr.current().progress.model_dump() == {"scanned": 42} + + asyncio.run(scenario()) diff --git a/deployments/entity-linkage/tests/test_link_api.py b/deployments/entity-linkage/tests/test_link_api.py index 88591641..35b15174 100644 --- a/deployments/entity-linkage/tests/test_link_api.py +++ b/deployments/entity-linkage/tests/test_link_api.py @@ -71,6 +71,9 @@ async def get_oil_gas_field_detail(self, resource_id: int) -> FieldDetailCandida raise self.detail_error return self.details_by_id[resource_id] + async def get_oil_gas_fields_total(self) -> int | None: + return len(self.items) + async def iter_oil_gas_fields( self, *, @@ -282,6 +285,14 @@ def test_link_all_launches_job_and_status_succeeds(test_client, install_client) assert result["merge_candidates_skipped"] == 0 assert fake.create_calls == [[1, 2]] + # The run publishes a final progress snapshot onto the record. + progress = final["progress"] + assert progress is not None + assert progress["resources_scanned"] == 3 + assert progress["total_resources"] == 3 + assert progress["merge_candidates_created"] == 1 + assert progress["updated_at"] is not None + def test_link_all_records_downstream_failure_in_status( test_client, install_client @@ -311,7 +322,9 @@ def test_link_all_rejects_concurrent_run_with_409( ) -> None: install_client() - async def slow_link_all(client, *, apply_merges, page_size, initiated_by): + async def slow_link_all( + client, *, apply_merges, page_size, initiated_by, on_progress=None + ): import asyncio await asyncio.sleep(0.5) diff --git a/deployments/entity-linkage/tests/test_matching.py b/deployments/entity-linkage/tests/test_matching.py index 617256d8..c49f8b00 100644 --- a/deployments/entity-linkage/tests/test_matching.py +++ b/deployments/entity-linkage/tests/test_matching.py @@ -45,6 +45,9 @@ async def get_oil_gas_field_detail(self, resource_id: int) -> FieldDetailCandida self.detail_calls.append(resource_id) return self.details_by_id[resource_id] + async def get_oil_gas_fields_total(self) -> int | None: + return len(self.items) + async def iter_oil_gas_fields( self, *, @@ -407,3 +410,39 @@ async def test_link_all_does_not_swallow_programming_errors() -> None: await matching.link_all( client, apply_merges=True, page_size=200, initiated_by="Tester" ) + + +@pytest.mark.anyio +async def test_link_all_reports_progress_via_callback() -> None: + # With fewer resources than PROGRESS_UPDATE_EVERY, only the final snapshot is + # emitted; it must carry the exact totals and the fetched denominator. + client = FakeMatchingClient( + items=[ + FieldCandidate(id=1, name="Alpha", country="US"), + FieldCandidate(id=2, name="alpha", country="US"), + FieldCandidate(id=3, name="Beta", country="CA"), + ], + details_by_id={ + 1: FieldDetailCandidate(id=1, name="Alpha", country="US"), + 2: FieldDetailCandidate(id=2, name="alpha", country="US"), + 3: FieldDetailCandidate(id=3, name="Beta", country="CA"), + }, + ) + + snapshots: list = [] + await matching.link_all( + client, + apply_merges=True, + page_size=200, + initiated_by="Tester", + on_progress=snapshots.append, + ) + + assert snapshots, "expected at least a final progress snapshot" + final = snapshots[-1] + assert final.resources_scanned == 3 + assert final.total_resources == 3 + assert final.merge_candidates_created == 1 + assert final.merge_candidates_skipped == 0 + assert final.resources_failed == 0 + assert final.updated_at is not None diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 89dfaa75..183ffb14 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -18,6 +18,12 @@ function getMatchGroups(result) { return Array.isArray(result?.match_groups) ? result.match_groups : []; } +function formatTimestamp(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toLocaleTimeString(); +} + function getResultDetails(result) { if (!result || typeof result !== "object" || Array.isArray(result)) { return result; @@ -78,6 +84,78 @@ function MatchGroupsSummary({ groups }) { ); } +// Expected-duration copy: the pass streams the whole dataset and routinely runs +// long, so we set that expectation wherever a run is in progress (STIT-740). +const RUN_DURATION_HINT = + "A full pass can take an hour or more on the production dataset."; + +function LinkProgressView({ progress }) { + const scanned = + typeof progress?.resources_scanned === "number" + ? progress.resources_scanned + : 0; + const total = + typeof progress?.total_resources === "number" + ? progress.total_resources + : null; + const percent = + total && total > 0 + ? Math.min(100, Math.round((scanned / total) * 100)) + : null; + const updatedAt = formatTimestamp(progress?.updated_at); + + return ( +
+
+

+ {total !== null + ? `Processing ${scanned.toLocaleString()} of ${total.toLocaleString()}` + : `Processing ${scanned.toLocaleString()}…`} +

+ {percent !== null && ( + + {percent}% + + )} +
+ +
+ {percent !== null ? ( +
+ ) : ( + // Total unknown: show an indeterminate bar rather than a false 0%. +
+ )} +
+ +

+ {formatCount(progress?.merge_candidates_created ?? 0, "candidate")}{" "} + created + {" · "} + {progress?.merge_candidates_skipped ?? 0} skipped + {" · "} + {progress?.resources_failed ?? 0} failed +

+ + {updatedAt && ( +

Last updated {updatedAt}

+ )} + +

{RUN_DURATION_HINT}

+
+ ); +} + function RunResult({ record }) { if (!record) { return ( @@ -117,9 +195,14 @@ function RunResult({ record }) { ) : record.state === "running" ? ( -

- Run in progress — status refreshes automatically. -

+ record.progress ? ( + + ) : ( +

+ Run in progress — status refreshes automatically.{" "} + {RUN_DURATION_HINT} +

+ ) ) : record.state === "failed" ? (
{record.error || "Run failed."} diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx index 9f92d0dd..fd5c8203 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx @@ -27,6 +27,23 @@ const SUCCEEDED_RECORD = { }, }; +const RUNNING_WITH_PROGRESS_RECORD = { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + finished_at: null, + error: null, + result: null, + progress: { + resources_scanned: 120, + total_resources: 1000, + merge_candidates_created: 4, + merge_candidates_skipped: 1, + resources_failed: 0, + updated_at: "2026-06-11T10:02:00Z", + }, +}; + function jsonResponse(status, body) { return { ok: status >= 200 && status < 300, @@ -124,6 +141,40 @@ describe("EntityLinkagePage", () => { ); }); + it("renders a progress bar with counts while a run reports progress", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + if (String(url) === START_URL && options?.method === "POST") { + return jsonResponse(202, { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }); + } + return jsonResponse(200, RUNNING_WITH_PROGRESS_RECORD); + }); + + renderWithQueryClient(); + + await userEvent.click(screen.getByRole("button", { name: "Start run" })); + await userEvent.click( + screen.getByRole("button", { name: "Refresh status" }), + ); + + await waitFor(() => { + expect(screen.getByText(/Processing/)).toBeInTheDocument(); + }); + + // 120 of 1000 -> 12%. + const bar = screen.getByRole("progressbar", { + name: "Linkage run progress", + }); + expect(bar).toHaveAttribute("aria-valuenow", "12"); + expect(screen.getByText("12%")).toBeInTheDocument(); + expect(screen.getByText(/4 candidates created/)).toBeInTheDocument(); + expect(screen.getByText(/Last updated/)).toBeInTheDocument(); + }); + it("surfaces a friendly message when a run is already in progress (409)", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( jsonResponse(409, { detail: "A job is already running: job-1" }), From 714af763bcf3376838d652a37cc5f453fb67154e Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 17:44:59 +0200 Subject: [PATCH 2/9] remove RUN_DURATION_HINT it was way understating --- .../stitch-frontend/src/pages/EntityLinkagePage.jsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 183ffb14..66eba3ed 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -84,11 +84,6 @@ function MatchGroupsSummary({ groups }) { ); } -// Expected-duration copy: the pass streams the whole dataset and routinely runs -// long, so we set that expectation wherever a run is in progress (STIT-740). -const RUN_DURATION_HINT = - "A full pass can take an hour or more on the production dataset."; - function LinkProgressView({ progress }) { const scanned = typeof progress?.resources_scanned === "number" @@ -150,8 +145,6 @@ function LinkProgressView({ progress }) { {updatedAt && (

Last updated {updatedAt}

)} - -

{RUN_DURATION_HINT}

); } @@ -199,8 +192,7 @@ function RunResult({ record }) { ) : (

- Run in progress — status refreshes automatically.{" "} - {RUN_DURATION_HINT} + Run in progress — status refreshes automatically.

) ) : record.state === "failed" ? ( From 1c353ac49127eac9d7621bcab0c44bbb77ed3e06 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 17:47:23 +0200 Subject: [PATCH 3/9] Select "Initiate Merges" by default, move checkbox --- .../src/pages/EntityLinkagePage.jsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 66eba3ed..3e3f4d41 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -217,7 +217,7 @@ export default function EntityLinkagePage() { const { getAccessTokenSilently } = useAuth0(); const baseUrl = config.entityLinkageBaseUrl; - const [applyMerges, setApplyMerges] = useState(false); + const [applyMerges, setApplyMerges] = useState(true); const [starting, setStarting] = useState(false); const [refreshing, setRefreshing] = useState(false); const [record, setRecord] = useState(null); @@ -351,17 +351,7 @@ export default function EntityLinkagePage() {
- - -
+
+ +
{error ? ( From 3b088f2eff5caaa243ca56fcb733553be399ac87 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 17:55:25 +0200 Subject: [PATCH 4/9] test(entity-linkage): cover linkage-progress UI edge cases Guard two branches of the Entity Linkage page that were previously untested: - the "Initiate merges" checkbox defaults to checked and Start run posts apply_merges:true, so a regression flipping the default back fails CI - LinkProgressView renders an indeterminate bar (no percent, no aria-valuenow) when the run reports an unknown total_resources Co-Authored-By: Claude Opus 4.8 --- .../src/pages/EntityLinkagePage.test.jsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx index fd5c8203..a0872080 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx @@ -44,6 +44,15 @@ const RUNNING_WITH_PROGRESS_RECORD = { }, }; +const RUNNING_WITHOUT_TOTAL_RECORD = { + ...RUNNING_WITH_PROGRESS_RECORD, + progress: { + ...RUNNING_WITH_PROGRESS_RECORD.progress, + resources_scanned: 120, + total_resources: null, + }, +}; + function jsonResponse(status, body) { return { ok: status >= 200 && status < 300, @@ -95,6 +104,35 @@ describe("EntityLinkagePage", () => { ); }); + it("defaults 'Initiate merges' on and posts apply_merges true", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse(202, { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }), + ); + + renderWithQueryClient(); + + expect( + screen.getByRole("checkbox", { name: "Initiate merges" }), + ).toBeChecked(); + + await userEvent.click(screen.getByRole("button", { name: "Start run" })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + START_URL, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ apply_merges: true }), + }), + ); + }); + }); + it("renders match groups from the polled job result", async () => { const fetchMock = vi .spyOn(globalThis, "fetch") @@ -175,6 +213,38 @@ describe("EntityLinkagePage", () => { expect(screen.getByText(/Last updated/)).toBeInTheDocument(); }); + it("renders an indeterminate bar when the total is unknown", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + if (String(url) === START_URL && options?.method === "POST") { + return jsonResponse(202, { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }); + } + return jsonResponse(200, RUNNING_WITHOUT_TOTAL_RECORD); + }); + + renderWithQueryClient(); + + await userEvent.click(screen.getByRole("button", { name: "Start run" })); + await userEvent.click( + screen.getByRole("button", { name: "Refresh status" }), + ); + + await waitFor(() => { + expect(screen.getByText(/Processing 120/)).toBeInTheDocument(); + }); + + // Unknown total -> no percent and no aria-valuenow (indeterminate bar). + const bar = screen.getByRole("progressbar", { + name: "Linkage run progress", + }); + expect(bar).not.toHaveAttribute("aria-valuenow"); + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); + }); + it("surfaces a friendly message when a run is already in progress (409)", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( jsonResponse(409, { detail: "A job is already running: job-1" }), From 215bb90f1340fd95feb6ed27709f9befba177247 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 17:56:43 +0200 Subject: [PATCH 5/9] include date in updated string --- deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 3e3f4d41..978a442d 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -21,7 +21,9 @@ function getMatchGroups(result) { function formatTimestamp(value) { if (!value) return null; const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date.toLocaleTimeString(); + // Date + time, not time alone: a linkage pass can span more than a day, so a + // bare time would be ambiguous across midnight (STIT-740). + return Number.isNaN(date.getTime()) ? null : date.toLocaleString(); } function getResultDetails(result) { From 15b2be37a66957f8b14f8234c85e1b14c27d5950 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 17:59:44 +0200 Subject: [PATCH 6/9] correct progress percent and trim linkage overhead Address review findings on the linkage progress feature: - floor the progress percent instead of rounding, so the bar no longer shows 100% while the run is still on its final ~0.5% of resources - fetch the resource total only when a progress consumer is listening, avoiding an extra API round trip for link_all callers that ignore progress Add tests covering the near-complete (99% not 100%) case and that the total fetch is skipped when no on_progress callback is passed. Co-Authored-By: Claude Opus 4.8 --- .../src/stitch/entity_linkage/matching.py | 18 ++++---- .../entity-linkage/tests/test_matching.py | 26 +++++++++++ .../src/pages/EntityLinkagePage.jsx | 4 +- .../src/pages/EntityLinkagePage.test.jsx | 43 +++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/matching.py b/deployments/entity-linkage/src/stitch/entity_linkage/matching.py index 53c5968e..a90c3e3a 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/matching.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/matching.py @@ -194,14 +194,16 @@ async def link_all( # candidate-list fetch entirely on a dry run. known_existing = await _existing_fingerprints(client) if apply_merges else None - # Denominator for progress; None if the count is unavailable. A failure here - # must not abort the pass, so fall back to an unknown total. - total_resources: int | None - try: - total_resources = await client.get_oil_gas_fields_total() - except (StitchAPIError, httpx.HTTPError, OSError) as exc: - logger.warning("Could not fetch resource total for progress: %s", exc) - total_resources = None + # Denominator for progress; only worth an extra request when a progress + # consumer is listening. None if unavailable -- a failure here must not abort + # the pass, so fall back to an unknown total. + total_resources: int | None = None + if on_progress is not None: + try: + total_resources = await client.get_oil_gas_fields_total() + except (StitchAPIError, httpx.HTTPError, OSError) as exc: + logger.warning("Could not fetch resource total for progress: %s", exc) + total_resources = None groups_by_fingerprint: dict[str, list[int]] = {} processed_ids: set[int] = set() diff --git a/deployments/entity-linkage/tests/test_matching.py b/deployments/entity-linkage/tests/test_matching.py index c49f8b00..896d9fdb 100644 --- a/deployments/entity-linkage/tests/test_matching.py +++ b/deployments/entity-linkage/tests/test_matching.py @@ -34,6 +34,7 @@ def __init__( self.iter_q: list[str | None] = [] self.create_calls: list[list[int]] = [] self.list_candidates_calls = 0 + self.total_calls = 0 async def __aenter__(self) -> "FakeMatchingClient": return self @@ -46,6 +47,7 @@ async def get_oil_gas_field_detail(self, resource_id: int) -> FieldDetailCandida return self.details_by_id[resource_id] async def get_oil_gas_fields_total(self) -> int | None: + self.total_calls += 1 return len(self.items) async def iter_oil_gas_fields( @@ -446,3 +448,27 @@ async def test_link_all_reports_progress_via_callback() -> None: assert final.merge_candidates_skipped == 0 assert final.resources_failed == 0 assert final.updated_at is not None + # The denominator is fetched when a progress consumer is listening. + assert client.total_calls == 1 + + +@pytest.mark.anyio +async def test_link_all_skips_total_fetch_without_progress_consumer() -> None: + # No on_progress: the denominator is never used, so the extra request that + # fetches it must be skipped. + client = FakeMatchingClient( + items=[ + FieldCandidate(id=1, name="Alpha", country="US"), + FieldCandidate(id=2, name="alpha", country="US"), + ], + details_by_id={ + 1: FieldDetailCandidate(id=1, name="Alpha", country="US"), + 2: FieldDetailCandidate(id=2, name="alpha", country="US"), + }, + ) + + await matching.link_all( + client, apply_merges=True, page_size=200, initiated_by="Tester" + ) + + assert client.total_calls == 0 diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 978a442d..350df77a 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -95,9 +95,11 @@ function LinkProgressView({ progress }) { typeof progress?.total_resources === "number" ? progress.total_resources : null; + // Floor, not round: rounding would show 100% while the run is still on its + // last ~0.5% of resources. Floor keeps it at 99% until the run is truly done. const percent = total && total > 0 - ? Math.min(100, Math.round((scanned / total) * 100)) + ? Math.min(100, Math.floor((scanned / total) * 100)) : null; const updatedAt = formatTimestamp(progress?.updated_at); diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx index a0872080..1e6c65f3 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx @@ -44,6 +44,16 @@ const RUNNING_WITH_PROGRESS_RECORD = { }, }; +const RUNNING_NEAR_COMPLETE_RECORD = { + ...RUNNING_WITH_PROGRESS_RECORD, + progress: { + ...RUNNING_WITH_PROGRESS_RECORD.progress, + // 9951/10000 = 99.51%: rounds to 100 but must floor to 99 while running. + resources_scanned: 9951, + total_resources: 10000, + }, +}; + const RUNNING_WITHOUT_TOTAL_RECORD = { ...RUNNING_WITH_PROGRESS_RECORD, progress: { @@ -213,6 +223,39 @@ describe("EntityLinkagePage", () => { expect(screen.getByText(/Last updated/)).toBeInTheDocument(); }); + it("does not reach 100% until the run is complete", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + if (String(url) === START_URL && options?.method === "POST") { + return jsonResponse(202, { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }); + } + return jsonResponse(200, RUNNING_NEAR_COMPLETE_RECORD); + }); + + renderWithQueryClient(); + + await userEvent.click(screen.getByRole("button", { name: "Start run" })); + await userEvent.click( + screen.getByRole("button", { name: "Refresh status" }), + ); + + await waitFor(() => { + expect(screen.getByText(/Processing/)).toBeInTheDocument(); + }); + + // 9951/10000 floors to 99, not 100, while still running. + const bar = screen.getByRole("progressbar", { + name: "Linkage run progress", + }); + expect(bar).toHaveAttribute("aria-valuenow", "99"); + expect(screen.getByText("99%")).toBeInTheDocument(); + expect(screen.queryByText("100%")).not.toBeInTheDocument(); + }); + it("renders an indeterminate bar when the total is unknown", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { if (String(url) === START_URL && options?.method === "POST") { From ce65fa1d10ca579c3113b3038b83b581c844e47e Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 18:04:26 +0200 Subject: [PATCH 7/9] test(entity-linkage): cover throttled mid-run progress emission link_all previously only had progress tests with <= 3 resources, exercising the final snapshot but never the periodic PROGRESS_UPDATE_EVERY heartbeat that delivers live progress during a long run. Add a test scanning 2*PROGRESS_UPDATE_EVERY+5 resources that asserts on_progress fires multiple times with strictly increasing resources_scanned, a single denominator fetch, and total_resources on every snapshot. Co-Authored-By: Claude Opus 4.8 --- .../entity-linkage/tests/test_matching.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/deployments/entity-linkage/tests/test_matching.py b/deployments/entity-linkage/tests/test_matching.py index 896d9fdb..03af506f 100644 --- a/deployments/entity-linkage/tests/test_matching.py +++ b/deployments/entity-linkage/tests/test_matching.py @@ -452,6 +452,43 @@ async def test_link_all_reports_progress_via_callback() -> None: assert client.total_calls == 1 +@pytest.mark.anyio +async def test_link_all_emits_progress_mid_run() -> None: + # Scan more than PROGRESS_UPDATE_EVERY resources so the throttled heartbeat + # fires during the run, not just the final snapshot. Unique names keep every + # resource a singleton (no match groups), isolating the progress cadence. + count = matching.PROGRESS_UPDATE_EVERY * 2 + 5 + items = [ + FieldCandidate(id=i, name=f"Field {i}", country="US") + for i in range(1, count + 1) + ] + details_by_id = { + i: FieldDetailCandidate(id=i, name=f"Field {i}", country="US") + for i in range(1, count + 1) + } + client = FakeMatchingClient(items=items, details_by_id=details_by_id) + + snapshots: list = [] + await matching.link_all( + client, + apply_merges=False, + page_size=200, + initiated_by="Tester", + on_progress=snapshots.append, + ) + + # Two throttled emits (at 100 and 200) plus the final snapshot at 205. + assert len(snapshots) >= 3 + scanned_values = [s.resources_scanned for s in snapshots] + assert scanned_values == sorted(scanned_values) + assert len(set(scanned_values)) == len(scanned_values) + assert scanned_values[0] == matching.PROGRESS_UPDATE_EVERY + assert snapshots[-1].resources_scanned == count + # Denominator is fetched once and carried on every snapshot. + assert client.total_calls == 1 + assert all(s.total_resources == count for s in snapshots) + + @pytest.mark.anyio async def test_link_all_skips_total_fetch_without_progress_consumer() -> None: # No on_progress: the denominator is never used, so the extra request that From 72e521bcb5ac88142648757b53e4bf10ae0d28de Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 18:08:57 +0200 Subject: [PATCH 8/9] fix(entity-linkage): keep progress indeterminate past a stale total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress denominator is a start-of-run snapshot, so on a long run a growing dataset can push resources_scanned to or past it. Rather than showing a stuck "100%" (or "3,050 of 3,000"), treat total-known-but-scanned>=total as indeterminate — same rendering as an unknown total. Add a test covering scanned > total while running. Co-Authored-By: Claude Opus 4.8 --- .../src/pages/EntityLinkagePage.jsx | 11 ++--- .../src/pages/EntityLinkagePage.test.jsx | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 350df77a..1422e6a1 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -95,19 +95,20 @@ function LinkProgressView({ progress }) { typeof progress?.total_resources === "number" ? progress.total_resources : null; + // The total is a start-of-run snapshot, so on a long run a growing dataset can + // push scanned to or past it while the pass is still going. Treat that (and an + // unknown total) as indeterminate rather than showing a stuck "100%". + const hasReliableTotal = total !== null && total > 0 && scanned < total; // Floor, not round: rounding would show 100% while the run is still on its // last ~0.5% of resources. Floor keeps it at 99% until the run is truly done. - const percent = - total && total > 0 - ? Math.min(100, Math.floor((scanned / total) * 100)) - : null; + const percent = hasReliableTotal ? Math.floor((scanned / total) * 100) : null; const updatedAt = formatTimestamp(progress?.updated_at); return (

- {total !== null + {hasReliableTotal ? `Processing ${scanned.toLocaleString()} of ${total.toLocaleString()}` : `Processing ${scanned.toLocaleString()}…`}

diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx index 1e6c65f3..97c1dd63 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx @@ -54,6 +54,16 @@ const RUNNING_NEAR_COMPLETE_RECORD = { }, }; +const RUNNING_OVER_TOTAL_RECORD = { + ...RUNNING_WITH_PROGRESS_RECORD, + progress: { + ...RUNNING_WITH_PROGRESS_RECORD.progress, + // Start-of-run total became stale as the dataset grew: scanned passed it. + resources_scanned: 3050, + total_resources: 3000, + }, +}; + const RUNNING_WITHOUT_TOTAL_RECORD = { ...RUNNING_WITH_PROGRESS_RECORD, progress: { @@ -256,6 +266,39 @@ describe("EntityLinkagePage", () => { expect(screen.queryByText("100%")).not.toBeInTheDocument(); }); + it("stays indeterminate when scanned passes a stale total (no stuck 100%)", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + if (String(url) === START_URL && options?.method === "POST") { + return jsonResponse(202, { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }); + } + return jsonResponse(200, RUNNING_OVER_TOTAL_RECORD); + }); + + renderWithQueryClient(); + + await userEvent.click(screen.getByRole("button", { name: "Start run" })); + await userEvent.click( + screen.getByRole("button", { name: "Refresh status" }), + ); + + await waitFor(() => { + expect(screen.getByText(/Processing 3,050/)).toBeInTheDocument(); + }); + + // scanned > total: no percent, no aria-valuenow, and no misleading "of". + const bar = screen.getByRole("progressbar", { + name: "Linkage run progress", + }); + expect(bar).not.toHaveAttribute("aria-valuenow"); + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); + expect(screen.queryByText(/of 3,000/)).not.toBeInTheDocument(); + }); + it("renders an indeterminate bar when the total is unknown", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { if (String(url) === START_URL && options?.method === "POST") { From a637e0578b97ba2ab4ae1abadb74762a20bede94 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 24 Sep 2026 18:23:28 +0200 Subject: [PATCH 9/9] address progress review feedback - emit an initial 0/total progress snapshot before scanning, so pollers see a denominator immediately and progress is observable on sub-100-resource runs - keep the progress bar determinate when scanned == total (clamped to 99%); only scanned > total (a stale, grown-past total) falls back to indeterminate - correct the JobManager docstring: the run body now receives the live JobRecord Add tests for the initial snapshot and the scanned == total boundary. Co-Authored-By: Claude Opus 4.8 --- .../src/stitch/entity_linkage/jobs.py | 3 +- .../src/stitch/entity_linkage/matching.py | 6 +++ .../entity-linkage/tests/test_matching.py | 15 ++++--- .../src/pages/EntityLinkagePage.jsx | 17 ++++--- .../src/pages/EntityLinkagePage.test.jsx | 44 +++++++++++++++++++ 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py b/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py index 643ea825..c5f225de 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/jobs.py @@ -54,7 +54,8 @@ class JobManager: """Single-job, in-memory run manager. State is lost on restart and concurrent runs are rejected. The run body is - supplied per start as a zero-arg coroutine, so this manager is generic. + supplied per start as a coroutine that receives the live ``JobRecord``, so it + can publish progress onto the record while it runs; this manager stays generic. """ def __init__(self) -> None: diff --git a/deployments/entity-linkage/src/stitch/entity_linkage/matching.py b/deployments/entity-linkage/src/stitch/entity_linkage/matching.py index a90c3e3a..b2f01470 100644 --- a/deployments/entity-linkage/src/stitch/entity_linkage/matching.py +++ b/deployments/entity-linkage/src/stitch/entity_linkage/matching.py @@ -226,6 +226,12 @@ def emit_progress() -> None: ) ) + # Publish a 0/total snapshot up front so a poller sees a denominator (and any + # progress at all) before the 100th resource -- and, for a run shorter than + # one throttle window, at all, since the state flips to succeeded right after + # the final snapshot with no yield in between. + emit_progress() + async for candidate in client.iter_oil_gas_fields(page_size=page_size): resources_scanned += 1 if resources_scanned % PROGRESS_UPDATE_EVERY == 0: diff --git a/deployments/entity-linkage/tests/test_matching.py b/deployments/entity-linkage/tests/test_matching.py index 03af506f..5a508974 100644 --- a/deployments/entity-linkage/tests/test_matching.py +++ b/deployments/entity-linkage/tests/test_matching.py @@ -416,8 +416,9 @@ async def test_link_all_does_not_swallow_programming_errors() -> None: @pytest.mark.anyio async def test_link_all_reports_progress_via_callback() -> None: - # With fewer resources than PROGRESS_UPDATE_EVERY, only the final snapshot is - # emitted; it must carry the exact totals and the fetched denominator. + # With fewer resources than PROGRESS_UPDATE_EVERY, an initial 0 snapshot and a + # final snapshot are emitted; the final must carry the exact totals and the + # fetched denominator. client = FakeMatchingClient( items=[ FieldCandidate(id=1, name="Alpha", country="US"), @@ -441,6 +442,9 @@ async def test_link_all_reports_progress_via_callback() -> None: ) assert snapshots, "expected at least a final progress snapshot" + # An up-front 0/total snapshot gives pollers a denominator immediately. + assert snapshots[0].resources_scanned == 0 + assert snapshots[0].total_resources == 3 final = snapshots[-1] assert final.resources_scanned == 3 assert final.total_resources == 3 @@ -477,12 +481,13 @@ async def test_link_all_emits_progress_mid_run() -> None: on_progress=snapshots.append, ) - # Two throttled emits (at 100 and 200) plus the final snapshot at 205. - assert len(snapshots) >= 3 + # Initial 0 snapshot, two throttled emits (100, 200), and the final one (205). + assert len(snapshots) >= 4 scanned_values = [s.resources_scanned for s in snapshots] assert scanned_values == sorted(scanned_values) assert len(set(scanned_values)) == len(scanned_values) - assert scanned_values[0] == matching.PROGRESS_UPDATE_EVERY + assert scanned_values[0] == 0 + assert matching.PROGRESS_UPDATE_EVERY in scanned_values assert snapshots[-1].resources_scanned == count # Denominator is fetched once and carried on every snapshot. assert client.total_calls == 1 diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx index 1422e6a1..c23e32a7 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.jsx @@ -95,13 +95,16 @@ function LinkProgressView({ progress }) { typeof progress?.total_resources === "number" ? progress.total_resources : null; - // The total is a start-of-run snapshot, so on a long run a growing dataset can - // push scanned to or past it while the pass is still going. Treat that (and an - // unknown total) as indeterminate rather than showing a stuck "100%". - const hasReliableTotal = total !== null && total > 0 && scanned < total; - // Floor, not round: rounding would show 100% while the run is still on its - // last ~0.5% of resources. Floor keeps it at 99% until the run is truly done. - const percent = hasReliableTotal ? Math.floor((scanned / total) * 100) : null; + // The total is a start-of-run snapshot. Only scanned > total means it went + // stale (dataset grew); equality is normal near the end of a run, so keep it + // determinate and treat only genuine overshoot (and an unknown total) as + // indeterminate rather than showing a stuck/false "100%". + const hasReliableTotal = total !== null && total > 0 && scanned <= total; + // Floor, not round; and clamp to 99, since this bar only shows while running so + // it should never read a "done"-looking 100%. + const percent = hasReliableTotal + ? Math.min(99, Math.floor((scanned / total) * 100)) + : null; const updatedAt = formatTimestamp(progress?.updated_at); return ( diff --git a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx index 97c1dd63..4e3cc512 100644 --- a/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx +++ b/deployments/stitch-frontend/src/pages/EntityLinkagePage.test.jsx @@ -54,6 +54,17 @@ const RUNNING_NEAR_COMPLETE_RECORD = { }, }; +const RUNNING_AT_TOTAL_RECORD = { + ...RUNNING_WITH_PROGRESS_RECORD, + progress: { + ...RUNNING_WITH_PROGRESS_RECORD.progress, + // Final 100-boundary of a run whose size is a multiple of 100: scanned == total + // while still running. Stays determinate, clamped to 99% (not a false 100%). + resources_scanned: 3000, + total_resources: 3000, + }, +}; + const RUNNING_OVER_TOTAL_RECORD = { ...RUNNING_WITH_PROGRESS_RECORD, progress: { @@ -266,6 +277,39 @@ describe("EntityLinkagePage", () => { expect(screen.queryByText("100%")).not.toBeInTheDocument(); }); + it("stays determinate at 99% when scanned equals total (still running)", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + if (String(url) === START_URL && options?.method === "POST") { + return jsonResponse(202, { + job_id: "job-1", + state: "running", + started_at: "2026-06-11T10:00:00Z", + initiated_by: "Test User", + }); + } + return jsonResponse(200, RUNNING_AT_TOTAL_RECORD); + }); + + renderWithQueryClient(); + + await userEvent.click(screen.getByRole("button", { name: "Start run" })); + await userEvent.click( + screen.getByRole("button", { name: "Refresh status" }), + ); + + await waitFor(() => { + expect(screen.getByText(/Processing 3,000 of 3,000/)).toBeInTheDocument(); + }); + + // scanned == total while running: determinate, clamped to 99%, never 100%. + const bar = screen.getByRole("progressbar", { + name: "Linkage run progress", + }); + expect(bar).toHaveAttribute("aria-valuenow", "99"); + expect(screen.getByText("99%")).toBeInTheDocument(); + expect(screen.queryByText("100%")).not.toBeInTheDocument(); + }); + it("stays indeterminate when scanned passes a stale total (no stuck 100%)", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { if (String(url) === START_URL && options?.method === "POST") {