mirror of
https://github.com/milvus-io/milvus.git
synced 2026-10-29 01:16:06 +08:00
## What this PR does Strengthens compaction coverage in the PR gate and removes false-positive and flaky paths. - preserves terminal success/failure/timeout outcomes after task cleanup and uses `EndTime` (with a legacy `StartTime` fallback) for post-finish GC retention - resolves target-based manual compaction through durable target metadata: an executable ACTIVE target remains `Executing` across reconciliation batches, INACTIVE completes only after every task is classified, inert ACTIVE targets and unknown/GCed IDs remain undefined, and `compact_id=-1` remains the explicit no-op sentinel - keeps unknown task states and invalid cleaned terminal states fail-closed even when their target is runtime-active - refuses successful aggregation when any task state or cleaned terminal outcome is unclassified, and covers INACTIVE targets with still-executing tasks - makes the Python compaction waiter consume structured plan counts with context metadata and one monotonic deadline across raw RPC attempts, reconnect, and backoff - restores channel recovery for `UNAVAILABLE` through the synchronous, timeout-aware SDK `reconnect()` interface without leaving a recovery worker that can mutate the handler after timeout - polls compaction-eligible segments through a single raw `GetPersistentSegmentInfo` RPC per observation, preserving call context and status mapping without the SDK retry decorator reusing a stale timeout - retries transient gRPC and rate-limit failures within the remaining deadline, and recognizes both current `code=600` and legacy `error_code=SegmentNotFound` wire formats - retries recoverable `Status.CONNECT_FAILED` reconnect-handshake failures in segment polling and PK pagination within the existing deadline/backoff; non-recoverable handshake errors still propagate - disables auto compaction before topology/concurrency setup and validates MixCompaction source/target multiplicity, exact topology, row count, PK uniqueness, and exact PK set - proves explicit ForceMerge `target_size` behavior with a fixed `segment.maxSize == 64MB` fixture: equivalent datasets must produce 3 outputs at 64MB and 2 outputs at 128MB - exposes finite ForceMerge QueryNode/DataNode memory factors in DataCoord system metrics; non-finite dynamic values fall back to the default before serialization - reads the unfiltered system topology and skips the target-size comparison when QueryNode/DataNode metrics are erroneous or the observed machine-safe ceiling cannot preserve the larger effective target - directly regression-tests the L3 machine-safe wrapper with healthy typed nodes plus an untyped metrics error, preventing a future return to filtered topology input - verifies every planned ForceMerge source leaves the active topology, every exposed target becomes active, the complete PK set survives, and physical rewrite bytes are conserved - releases and reloads the collection before MixCompaction and ForceMerge PK validation, then verifies the complete loaded segment set, retired-source exclusion, and node assignments so readable old inputs cannot mask missing compaction outputs - submits reload asynchronously through single-attempt SDK calls and polls loading progress with fresh remaining budgets; release, load submission, progress, and topology checks share one monotonic deadline - replaces `QueryIterator` validation with deterministic PK-range pages; every raw query RPC receives a freshly calculated remaining timeout, retains MilvusClient `cluster_id`, and retries transient gRPC/rate-limit failures within the same total deadline - verifies reconnect receives only the remaining pagination deadline, an over-budget reconnect cannot issue another query RPC, ignored gRPC codes and real rc83 non-rate-limit business statuses fail fast, and both current and legacy `(code=0, compatible_code=49)` rate-limit mappings retry within budget - preserves valid ForceMerge QueryNode/DataNode memory factors, while non-finite values fall back and all finite factors below 1—including positive fractional values—clamp to 1 - sustains checked insert/CreateIndex/search workloads across compaction and propagates every worker failure, including the last in-flight request during cleanup; an Executing sample counts as overlap evidence only when the same workload call spans the observation - waits for two matching Flushed/L1/sorted source snapshots before the index workload, then expects unselected sources plus Mix targets; selected sources must retire and every loaded segment must have an index and node assignment - validates final PKs, indexed targets, and search results before marking an unobserved overlap window as insufficient coverage (pytest skip); repeated CreateIndex requests establish request concurrency, not repeated index builds - validates complete binary-search results against exact Jaccard ground truth - adds DataCoord aggregation/target lifecycle UT and compaction stats-loader UT coverage `CompactionMergeInfo` exposes one legacy `target` field although a ForceMerge task may split into multiple result segments. Split-output validation therefore requires each exposed target to be active and separately requires every planned source to be retired; complete active-output multiplicity is verified through the persistent-segment topology. Fixes #53272 ## Test plan - [x] Partial-plan review follow-up (2026-10-22): 82 focused local tests passed with Python 3.12 / PyMilvus rc98, plus Ruff lint/format and diff checks. The regression invokes the actual index E2E case with an 8-of-9 Mix plan: the prior implementation times out, the fix accepts the valid indexed target plus unselected source. Negative cases reject retained selected sources, missing/unexpected/unindexed/unassigned segments, and missing PKs; the ORM adapter exercises sorting convergence and context preservation. - [ ] Live index concurrency rerun is blocked: both Dev CLI deployment-list and `deploy status compaction-pr-ci` requests return HTTP 500. No live-cluster pass is claimed for this follow-up. - [x] Review follow-up on 2026-10-22: Python 3.12 / pinned PyMilvus 3.1.0rc98 focused suite: 72 passed; Ruff lint/format and diff whitespace checks passed; all three revised concurrency E2E cases collect successfully. Fault injection covers fast completion, unobserved overlap, worker failures before and after terminal observation, and cleanup. Real SDK loading regressions fail on the previous implementation (5-second budget returns at t=8) and pass with fresh per-poll budgets. - [ ] Re-run the three sustained-workload cases and release/reload integrity cases against a live cluster; this follow-up has local regression coverage only. - [ ] Full `make verifiers` attempted on 2026-10-22 but stopped in `build-3rdparty`: this macOS host lacks a supported LLVM 14–18 toolchain. - [x] Review follow-up on 2026-10-20: Python 3.12 / pinned PyMilvus 3.1.0rc98 focused suite: 56 passed; Ruff lint/format, Python syntax, and diff whitespace checks passed. Reconnect regressions exercise the actual SDK reconnect/readiness functions; topology tests inject delayed/partial/stuck loading, reload failures, and late responses. - [ ] Re-run live MixCompaction / ForceMerge integrity E2E with the new release/reload validation; the review follow-up was verified locally with fault-injection tests and server-path inspection. - [x] Go formatting, Python 3.12 compile, Ruff check/format, protobuf submodule compile, and `git diff --check` - [x] Local Python 3.12 waiter/raw-adapter/pagination/machine-safe matrix: 38 passed - [x] Local focused paramtable + metrics UT: `go test -tags dynamic,test -gcflags='all=-N -l'` - [x] Dev CLI exact non-finite memory-factor/serialization UT: `go-ut-local-yanliang-test-compaction-pr-5aa7d24-ce47cff0-u3805` - [x] Dev CLI exact full memory-factor preservation/fallback matrix: `go-ut-local-yanliang-test-compaction-pr-fab7e57-ce47cff0-u3805` - [x] Dev CLI exact fractional-boundary memory-factor matrix: `go-ut-local-yanliang-test-compaction-pr-3152b2a-ce47cff0-u3805` - [ ] Dev CLI targeted retry L0 could not start pytest because the runner Pod could not resolve the COS source host: `pysdk-e2e-local-yanliang-test-compaction-d642871-420126f0-u3805`; the same 3-case rc83 test passes locally - [x] Dev CLI current server image build: `build-local-yanliang-qia-test-compac-5aa7d24-bf331245b298-u3805` - [x] Deploy `harbor.tencent-stage.zilliz.cn/manta/milvus:local-u3805-test-compaction-pr-ci-coverage-5aa7d24-bf331245b298` to `compaction-pr-ci`; verify `Healthy` and `updated=true` - [x] Dev CLI current DataCoord + metrics UT: `go-ut-local-yanliang-test-compaction-pr-d3b086b-57710439-u3805` - [x] Dev CLI exact GC-retention suite: `go-ut-local-yanliang-test-compaction-pr-c9996b9-29281152-u3805` - [x] Dev CLI full `internal/compaction` + `internal/datacoord` Go UT for the broader PR changes: `go-ut-local-yanliang-test-compaction-pr-7b125fe-6ba90598-u3805` - [x] Dev CLI real no-op trigger plus unknown-ID E2E (3/3): `pysdk-e2e-local-yanliang-test-compaction-710c3bc-cef493b1-u3805` - [x] Dev CLI exact binary-search validation (1/1): `pysdk-e2e-local-yanliang-test-compaction-0728a4d-714e12a8-u3805` - [ ] Run the final fixed 64/128MB ForceMerge topology/full-PK E2E. The latest local-source runner still fails before pytest because its Pod cannot resolve the COS source host: `pysdk-e2e-local-yanliang-test-compaction-5aa7d24-4e733021-u3805`; the PR-source channel independently cannot resolve GitHub: `pysdk-e2e-pr-53273-5aa7d24-8424db8b-u3805` - [ ] Re-run the final Mix multiplicity assertion and insert/index result-binding cases after the same source-runner DNS incident is resolved ## Notes Upgrade, chaos, performance, and experimental-configuration coverage remain lower-priority follow-up work. Signed-off-by: Yanliang Qiao <yanliang.qiao@zilliz.com>
420 lines
16 KiB
Python
420 lines
16 KiB
Python
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass
|
|
from inspect import unwrap
|
|
|
|
import grpc
|
|
from pymilvus.client.call_context import _api_level_md
|
|
from pymilvus.client.grpc_handler import GrpcHandler
|
|
from pymilvus.client.prepare import Prepare
|
|
from pymilvus.client.types import SegmentInfo, Status
|
|
from pymilvus.client.utils import check_status
|
|
from pymilvus.decorators import IGNORE_RETRY_CODES
|
|
from pymilvus.exceptions import ErrorCode, MilvusException
|
|
from pymilvus.grpc_gen import common_pb2
|
|
|
|
UNSUCCESSFUL_COMPACTION_STATES = frozenset({"UndefiedState", "UndefinedState"})
|
|
INITIAL_RETRY_BACKOFF = 0.01
|
|
MAX_RETRY_BACKOFF = 3
|
|
RETRY_BACKOFF_MULTIPLIER = 3
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CompactionStateInfo:
|
|
state: str
|
|
executing_plan_no: int = 0
|
|
completed_plan_no: int = 0
|
|
failed_plan_no: int = 0
|
|
timeout_plan_no: int = 0
|
|
|
|
|
|
def reload_compaction_outputs(
|
|
client,
|
|
collection_name,
|
|
expected_segment_ids,
|
|
retired_segment_ids,
|
|
timeout=180,
|
|
poll_interval=1,
|
|
):
|
|
"""Reload a quiescent collection and verify its output topology before querying PKs.
|
|
|
|
DataCoord completion does not imply QueryNode handoff. ReleaseCollection
|
|
waits for the old segment/channel distribution to drain; a fresh load then
|
|
cannot serve the old readable snapshot. GetQuerySegmentInfo alone is not
|
|
this barrier: QueryCoord filters its result against the current target.
|
|
"""
|
|
expected_ids = set(expected_segment_ids)
|
|
retired_ids = set(retired_segment_ids)
|
|
if not expected_ids or expected_ids & retired_ids:
|
|
raise ValueError("expected segments must be nonempty and disjoint from retired segments")
|
|
if timeout <= 0 or poll_interval <= 0:
|
|
raise ValueError("timeout and poll_interval must be positive")
|
|
|
|
deadline = time.monotonic() + timeout
|
|
loaded_ids = set()
|
|
unassigned_ids = set()
|
|
|
|
def remaining():
|
|
budget = deadline - time.monotonic()
|
|
if budget <= 0:
|
|
raise AssertionError(
|
|
f"{collection_name} loaded topology did not match compaction outputs within {timeout:g} seconds; "
|
|
f"expected={sorted(expected_ids)}, loaded={sorted(loaded_ids)}, "
|
|
f"retired={sorted(retired_ids)}, unassigned={sorted(unassigned_ids)}"
|
|
)
|
|
return budget
|
|
|
|
handler = client._get_connection()
|
|
context = client._generate_call_context()
|
|
# Single-attempt SDK calls: decorators and synchronous loading polls reuse
|
|
# the supplied timeout, so neither may own this helper's total deadline.
|
|
unwrap(GrpcHandler.release_collection)(handler, collection_name, timeout=remaining(), context=context)
|
|
unwrap(GrpcHandler.load_collection)(handler, collection_name, timeout=remaining(), context=context, _async=True)
|
|
while True:
|
|
progress = unwrap(GrpcHandler.get_loading_progress)(
|
|
handler, collection_name, timeout=remaining(), context=context
|
|
)
|
|
budget = remaining()
|
|
if progress >= 100:
|
|
break
|
|
time.sleep(min(poll_interval, budget))
|
|
while True:
|
|
# Bypass the SDK retry decorator so each observation has a fresh budget.
|
|
response = client._get_connection()._stub.GetQuerySegmentInfo(
|
|
Prepare.get_query_segment_info_request(collection_name),
|
|
timeout=remaining(),
|
|
metadata=_api_level_md(client._generate_call_context()),
|
|
)
|
|
check_status(response.status)
|
|
loaded_ids = {segment.segmentID for segment in response.infos}
|
|
unassigned_ids = {segment.segmentID for segment in response.infos if not segment.nodeIds}
|
|
budget = remaining() # A late matching response must not pass.
|
|
if loaded_ids == expected_ids and loaded_ids.isdisjoint(retired_ids) and not unassigned_ids:
|
|
return
|
|
time.sleep(min(poll_interval, budget))
|
|
|
|
|
|
def get_compaction_state_info(handler, compact_id: int, timeout: float, context=None) -> CompactionStateInfo:
|
|
deadline = time.monotonic() + timeout
|
|
backoff = INITIAL_RETRY_BACKOFF
|
|
while True:
|
|
try:
|
|
state = _get_compaction_state_info(handler, compact_id, deadline=deadline, context=context)
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"timed out getting state for compaction {compact_id}")
|
|
return state
|
|
except grpc.RpcError as error:
|
|
if error.code() in IGNORE_RETRY_CODES:
|
|
raise
|
|
|
|
# GrpcHandler.reconnect() is a synchronous, timeout-aware SDK
|
|
# interface. It replaces a shutdown/stale channel before the next
|
|
# raw RPC without leaving a recovery worker behind after timeout.
|
|
if error.code() == grpc.StatusCode.UNAVAILABLE and hasattr(handler, "reconnect"):
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError(f"timed out getting state for compaction {compact_id}") from error
|
|
try:
|
|
handler.reconnect(timeout=remaining)
|
|
except MilvusException as reconnect_error:
|
|
if reconnect_error.code != Status.CONNECT_FAILED:
|
|
raise
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError(f"timed out getting state for compaction {compact_id}") from error
|
|
|
|
remaining = deadline - time.monotonic()
|
|
# Never start a backoff that consumes the remaining RPC budget.
|
|
if remaining <= backoff:
|
|
raise TimeoutError(f"timed out getting state for compaction {compact_id}") from error
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * RETRY_BACKOFF_MULTIPLIER, MAX_RETRY_BACKOFF)
|
|
except MilvusException as error:
|
|
should_retry = error.code == ErrorCode.RATE_LIMIT or error.compatible_code == common_pb2.RateLimit
|
|
if not should_retry:
|
|
raise
|
|
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= backoff:
|
|
raise TimeoutError(f"timed out getting state for compaction {compact_id}") from error
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * RETRY_BACKOFF_MULTIPLIER, MAX_RETRY_BACKOFF)
|
|
|
|
|
|
def _get_compaction_state_info(
|
|
handler,
|
|
compact_id: int,
|
|
*,
|
|
deadline: float,
|
|
context=None,
|
|
) -> CompactionStateInfo:
|
|
# The high-level PySDK API returns only state_name and drops failedPlanNo.
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError(f"timed out getting state for compaction {compact_id}")
|
|
request = Prepare.get_compaction_state(compact_id)
|
|
response = handler._stub.GetCompactionState(
|
|
request,
|
|
timeout=remaining,
|
|
metadata=_api_level_md(context),
|
|
)
|
|
check_status(response.status)
|
|
return CompactionStateInfo(
|
|
state=common_pb2.CompactionState.Name(response.state),
|
|
executing_plan_no=response.executingPlanNo,
|
|
completed_plan_no=response.completedPlanNo,
|
|
failed_plan_no=response.failedPlanNo,
|
|
timeout_plan_no=response.timeoutPlanNo,
|
|
)
|
|
|
|
|
|
def wait_for_compaction_completed(
|
|
get_state: Callable[[int, float], CompactionStateInfo],
|
|
compact_id: int,
|
|
timeout: float,
|
|
poll_interval: float = 2,
|
|
monotonic: Callable[[], float] = time.monotonic,
|
|
sleep: Callable[[float], None] = time.sleep,
|
|
) -> bool:
|
|
"""Poll a compaction job and fail with useful context when it does not finish."""
|
|
if timeout <= 0:
|
|
raise ValueError("timeout must be greater than zero")
|
|
if poll_interval <= 0:
|
|
raise ValueError("poll_interval must be greater than zero")
|
|
if compact_id == -1:
|
|
return True
|
|
|
|
deadline = monotonic() + timeout
|
|
last_state = None
|
|
state_history = []
|
|
while True:
|
|
remaining = deadline - monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
|
|
last_state = get_state(compact_id, remaining)
|
|
state_history.append(last_state)
|
|
# Enforce the caller's deadline even when the RPC returns a terminal
|
|
# response after consuming its entire timeout budget.
|
|
if monotonic() >= deadline:
|
|
break
|
|
if last_state.failed_plan_no or last_state.timeout_plan_no:
|
|
raise AssertionError(
|
|
f"compaction {compact_id} reported unsuccessful plans in state {last_state.state}; "
|
|
f"failed plans: {last_state.failed_plan_no}; timed out plans: {last_state.timeout_plan_no}; "
|
|
f"state history: {state_history}"
|
|
)
|
|
if last_state.state == "Completed":
|
|
return True
|
|
if last_state.state in UNSUCCESSFUL_COMPACTION_STATES:
|
|
raise AssertionError(
|
|
f"compaction {compact_id} entered unsuccessful terminal state: {last_state.state}; "
|
|
f"state history: {state_history}"
|
|
)
|
|
|
|
remaining = deadline - monotonic()
|
|
if remaining > 0:
|
|
sleep(min(poll_interval, remaining))
|
|
|
|
raise AssertionError(
|
|
f"compaction {compact_id} did not complete within {timeout:g} seconds; "
|
|
f"last state: {last_state.state if last_state else None}; state history: {state_history}"
|
|
)
|
|
|
|
|
|
def run_compaction_with_workload(trigger, get_state, operation, timeout=300, poll_interval=0.01):
|
|
"""Sustain a checked workload until compaction completes; return overlap evidence.
|
|
|
|
Each callback accepts its remaining timeout. An Executing observation counts
|
|
only if the same workload call spans the state RPC. Completed without such
|
|
an observation is insufficient coverage, not evidence of a product failure.
|
|
The caller must check final results before reporting that coverage gap.
|
|
"""
|
|
if timeout <= 0 or poll_interval <= 0:
|
|
raise ValueError("timeout and poll_interval must be positive")
|
|
deadline = time.monotonic() + timeout
|
|
stop = threading.Event()
|
|
started = threading.Event()
|
|
lock = threading.Lock()
|
|
active_call = None
|
|
observed_overlap = False
|
|
|
|
def remaining():
|
|
budget = deadline - time.monotonic()
|
|
if budget <= 0:
|
|
raise TimeoutError("compaction workload exceeded its deadline")
|
|
return budget
|
|
|
|
def worker():
|
|
nonlocal active_call
|
|
while not stop.is_set():
|
|
with lock:
|
|
active_call = object()
|
|
started.set()
|
|
try:
|
|
operation(remaining())
|
|
finally:
|
|
with lock:
|
|
active_call = None
|
|
stop.wait(min(poll_interval, remaining()))
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(worker)
|
|
try:
|
|
while not started.wait(min(poll_interval, remaining())):
|
|
if future.done():
|
|
future.result()
|
|
if future.done():
|
|
future.result()
|
|
trigger(remaining())
|
|
|
|
def observe(_compact_id, rpc_timeout):
|
|
nonlocal observed_overlap
|
|
if future.done():
|
|
future.result()
|
|
with lock:
|
|
before = active_call
|
|
state = get_state(rpc_timeout)
|
|
with lock:
|
|
if before is not None and active_call is before and state.state == "Executing":
|
|
observed_overlap = True
|
|
if future.done():
|
|
future.result()
|
|
return state
|
|
|
|
wait_for_compaction_completed(observe, 0, remaining(), poll_interval=poll_interval)
|
|
finally:
|
|
stop.set()
|
|
# Also surface failures from the last in-flight call after the
|
|
# terminal observation, including during cleanup of a main failure.
|
|
future.result()
|
|
return observed_overlap
|
|
|
|
|
|
def list_persistent_segments_once(handler, collection_name, timeout, context=None):
|
|
"""Issue exactly one persistent-segment RPC with the supplied budget."""
|
|
request = Prepare.get_persistent_segment_info_request(collection_name)
|
|
response = handler._stub.GetPersistentSegmentInfo(
|
|
request,
|
|
timeout=timeout,
|
|
metadata=_api_level_md(context),
|
|
)
|
|
check_status(response.status)
|
|
return [
|
|
SegmentInfo(
|
|
info.segmentID,
|
|
info.collectionID,
|
|
collection_name,
|
|
info.num_rows,
|
|
info.is_sorted,
|
|
info.state,
|
|
info.level,
|
|
info.storage_version,
|
|
)
|
|
for info in response.infos
|
|
]
|
|
|
|
|
|
def wait_for_compaction_eligible_segments(
|
|
handler,
|
|
collection_name,
|
|
minimum_segment_count,
|
|
timeout=30,
|
|
poll_interval=2,
|
|
context=None,
|
|
):
|
|
deadline = time.monotonic() + timeout
|
|
previous_snapshot = None
|
|
stable_observations = 0
|
|
latest_segments = []
|
|
retry_backoff = INITIAL_RETRY_BACKOFF
|
|
|
|
while time.monotonic() < deadline:
|
|
remaining = deadline - time.monotonic()
|
|
try:
|
|
latest_segments = list_persistent_segments_once(
|
|
handler, collection_name, timeout=remaining, context=context
|
|
)
|
|
except grpc.RpcError as error:
|
|
if error.code() in IGNORE_RETRY_CODES:
|
|
raise
|
|
if error.code() == grpc.StatusCode.UNAVAILABLE and hasattr(handler, "reconnect"):
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
try:
|
|
handler.reconnect(timeout=remaining)
|
|
except MilvusException as reconnect_error:
|
|
if reconnect_error.code != Status.CONNECT_FAILED:
|
|
raise
|
|
if time.monotonic() >= deadline:
|
|
break
|
|
previous_snapshot = None
|
|
stable_observations = 0
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= retry_backoff:
|
|
break
|
|
time.sleep(retry_backoff)
|
|
retry_backoff = min(
|
|
retry_backoff * RETRY_BACKOFF_MULTIPLIER,
|
|
MAX_RETRY_BACKOFF,
|
|
)
|
|
continue
|
|
except MilvusException as error:
|
|
is_segment_not_found = (error.code == 600 or error.compatible_code == common_pb2.SegmentNotFound) and (
|
|
"segment not found" in error.message.lower()
|
|
)
|
|
is_rate_limit = error.code == ErrorCode.RATE_LIMIT or error.compatible_code == common_pb2.RateLimit
|
|
if not (is_segment_not_found or is_rate_limit):
|
|
raise
|
|
previous_snapshot = None
|
|
stable_observations = 0
|
|
remaining = deadline - time.monotonic()
|
|
delay = poll_interval if is_segment_not_found else retry_backoff
|
|
if remaining <= delay:
|
|
break
|
|
time.sleep(delay)
|
|
if is_rate_limit:
|
|
retry_backoff = min(
|
|
retry_backoff * RETRY_BACKOFF_MULTIPLIER,
|
|
MAX_RETRY_BACKOFF,
|
|
)
|
|
continue
|
|
if time.monotonic() >= deadline:
|
|
break
|
|
retry_backoff = INITIAL_RETRY_BACKOFF
|
|
snapshot = tuple(
|
|
sorted(
|
|
(
|
|
segment.segment_id,
|
|
segment.num_rows,
|
|
segment.state_name,
|
|
segment.level_name,
|
|
segment.is_sorted,
|
|
)
|
|
for segment in latest_segments
|
|
)
|
|
)
|
|
all_segments_eligible = len(snapshot) >= minimum_segment_count and all(
|
|
segment.state_name == "Flushed" and segment.level_name == "L1" and segment.is_sorted
|
|
for segment in latest_segments
|
|
)
|
|
if all_segments_eligible and snapshot == previous_snapshot:
|
|
stable_observations += 1
|
|
if stable_observations >= 2:
|
|
return latest_segments
|
|
else:
|
|
stable_observations = 1 if all_segments_eligible else 0
|
|
previous_snapshot = snapshot
|
|
|
|
remaining = deadline - time.monotonic()
|
|
if remaining > 0:
|
|
time.sleep(min(poll_interval, remaining))
|
|
|
|
raise AssertionError(
|
|
f"Segments for {collection_name} did not become stable compaction candidates "
|
|
f"(Flushed, L1, sorted) within {timeout} seconds; "
|
|
f"last snapshot: {previous_snapshot}"
|
|
)
|