diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 4867807e9..14608d53b 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -286,7 +286,7 @@ jobs: # associated with roxanne+test_edge account since for some reason it was failing with # prod biggies GROUNDLIGHT_API_TOKEN: ${{ secrets.K3S_TEST_GROUNDLIGHT_API_TOKEN }} - USE_MINIMAL_IMAGE: true + INFERENCE_IMAGE_MODE: fully_minimal steps: - name: Check out code uses: actions/checkout@v4 diff --git a/app/api/routes/image_queries.py b/app/api/routes/image_queries.py index 796969471..1bc7acedf 100644 --- a/app/api/routes/image_queries.py +++ b/app/api/routes/image_queries.py @@ -14,6 +14,7 @@ refresh_detector_metadata_if_needed, ) from app.core.edge_config_manager import EdgeConfigManager +from app.core.inference_image import detector_uses_minimal_image from app.core.naming import get_edge_inference_model_name from app.core.utils import create_iq, generate_iq_id, generate_metadata_dict, generate_request_id from app.escalation_queue.models import SubmitImageQueryParams @@ -288,7 +289,7 @@ async def post_image_query( # noqa: PLR0913, PLR0915, PLR0912 } ) - if app_state.separate_oodd_inference: + if not detector_uses_minimal_image(detector_id, app_state.db_manager): oodd_model_name = get_edge_inference_model_name(detector_id=detector_id, is_oodd=True) app_state.db_manager.create_or_update_inference_deployment_record( deployment={ diff --git a/app/core/app_state.py b/app/core/app_state.py index ff4927af0..ec0e20be6 100644 --- a/app/core/app_state.py +++ b/app/core/app_state.py @@ -1,5 +1,4 @@ import logging -import os import time from functools import lru_cache @@ -22,8 +21,6 @@ MAX_DETECTOR_IDS_CACHE_SIZE = 1000 STALE_METADATA_THRESHOLD_SEC = 60 # 60 seconds -USE_MINIMAL_IMAGE = os.environ.get("USE_MINIMAL_IMAGE", "false") == "true" - @lru_cache(maxsize=MAX_SDK_INSTANCES_CACHE_SIZE) @trace_span @@ -101,12 +98,8 @@ def get_detector_metadata(detector_id: str, gl: Groundlight) -> Detector: class AppState: def __init__(self): - # We only launch a separate OODD inference pod if we are not using the minimal image. - # Pipelines used in the minimal image include OODD inference and confidence adjustment, - # so they do not need to be adjusted separately. - self.separate_oodd_inference = not USE_MINIMAL_IMAGE - self.edge_inference_manager = EdgeInferenceManager(separate_oodd_inference=self.separate_oodd_inference) self.db_manager = DatabaseManager() + self.edge_inference_manager = EdgeInferenceManager(db_manager=self.db_manager) self.is_ready = False self.queue_writer = QueueWriter() diff --git a/app/core/database.py b/app/core/database.py index 30d0cefbd..abeac6f43 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -9,6 +9,7 @@ from app.core.file_paths import DATABASE_FILEPATH, DATABASE_ORM_LOG_FILE, DATABASE_ORM_LOG_FILE_SIZE from app.core.models import Base, InferenceDeployment +from app.core.naming import get_edge_inference_model_name logger = logging.getLogger(__name__) @@ -102,6 +103,13 @@ def update_inference_deployment_record(self, model_name: str, fields_to_update: setattr(detector_record, field, value) session.commit() + def get_inference_deployment_record(self, detector_id: str, is_oodd: bool = False) -> InferenceDeployment | None: + """Return the primary or OODD record for a detector, or None if it doesn't exist.""" + model_name = get_edge_inference_model_name(detector_id, is_oodd=is_oodd) + with self.session_maker() as session: + query = select(InferenceDeployment).filter_by(model_name=model_name) + return session.execute(query).scalar_one_or_none() + def get_inference_deployment_records(self, **kwargs) -> Sequence[InferenceDeployment]: """ Query the database table for detectors based on a given query predicate. diff --git a/app/core/edge_inference.py b/app/core/edge_inference.py index c3bab3900..17381138b 100644 --- a/app/core/edge_inference.py +++ b/app/core/edge_inference.py @@ -15,8 +15,15 @@ from jinja2 import Template from model import ModeEnum +from app.core.database import DatabaseManager from app.core.edge_config_manager import EdgeConfigManager from app.core.file_paths import MODEL_REPOSITORY_PATH +from app.core.inference_image import ( + INFERENCE_IMAGE_MODE, + MODE_FULLY_MINIMAL, + MODE_STANDARD, + detector_uses_minimal_image, +) from app.core.naming import ( get_detector_models_dir, get_edge_inference_service_name, @@ -33,6 +40,16 @@ # This will be process-specific, so each edge-endpoint worker will have its own cache instance. ttl_cache = TTLCache(maxsize=128, ttl=5) +# Per-worker TTL cache for the "run separate OODD?" decision. Keyed by detector_id only (db_manager dropped from key) +# so repeated inference calls skip the DB read. TTL matches is_edge_inference_ready so staleness windows align; +# a minimal_compatible flip triggers a pod redeployment that takes far longer than 5s to complete. +_separate_oodd_cache: TTLCache = TTLCache(maxsize=128, ttl=5) + + +@cached(_separate_oodd_cache, key=lambda detector_id, db_manager: detector_id) +def _uses_separate_oodd_cached(detector_id: str, db_manager) -> bool: + return not detector_uses_minimal_image(detector_id, db_manager) + @cached(ttl_cache) def is_edge_inference_ready(inference_client_url: str) -> bool: @@ -258,27 +275,26 @@ class EdgeInferenceManager: def __init__( self, + db_manager: DatabaseManager, verbose: bool = False, - separate_oodd_inference: bool = True, ) -> None: self.verbose = verbose + self.db_manager = db_manager self.speedmon = SpeedMonitor() - self.separate_oodd_inference = separate_oodd_inference self.last_escalation_times: dict[str, float | None] = {} + def uses_separate_oodd(self, detector_id: str) -> bool: + """Whether this detector runs OODD as a separate pod (full image) vs folded into primary (minimal image).""" + return _uses_separate_oodd_cached(detector_id, self.db_manager) + @trace_span def inference_is_available(self, detector_id: str) -> bool: """Check whether inference pods for this detector are ready to serve.""" + separate_oodd = self.uses_separate_oodd(detector_id) primary_url = get_edge_inference_service_name(detector_id) + ":8000" - oodd_url = ( - get_edge_inference_service_name(detector_id, is_oodd=True) + ":8000" - if self.separate_oodd_inference - else None - ) + oodd_url = get_edge_inference_service_name(detector_id, is_oodd=True) + ":8000" if separate_oodd else None - ready = is_edge_inference_ready(primary_url) and ( - not self.separate_oodd_inference or is_edge_inference_ready(oodd_url) - ) + ready = is_edge_inference_ready(primary_url) and (not separate_oodd or is_edge_inference_ready(oodd_url)) if not ready: logger.debug( f"Edge inference server and/or OODD inference server is not ready. {primary_url=}, {oodd_url=}" @@ -308,8 +324,9 @@ def run_inference(self, detector_id: str, image_bytes: bytes, content_type: str, logger.info(f"Submitting image to edge inference service. {detector_id=}") start_time = time.perf_counter() + separate_oodd = self.uses_separate_oodd(detector_id) primary_url = get_edge_inference_service_name(detector_id) + ":8000" - if self.separate_oodd_inference: + if separate_oodd: oodd_url = get_edge_inference_service_name(detector_id, is_oodd=True) + ":8000" with ThreadPoolExecutor(max_workers=2) as executor: if get_current_tracer() is not None: @@ -340,7 +357,7 @@ def run_inference(self, detector_id: str, image_bytes: bytes, content_type: str, primary_dir = get_primary_edge_model_dir(self.MODEL_REPOSITORY, detector_id) if mlb_key := get_current_model_ksuid(primary_dir, primary_version): output_dict["mlb_key"] = mlb_key - if self.separate_oodd_inference and oodd_response is not None: + if separate_oodd and oodd_response is not None: oodd_version = get_current_model_version(self.MODEL_REPOSITORY, detector_id, is_oodd=True) oodd_dir = get_oodd_model_dir(self.MODEL_REPOSITORY, detector_id) if oodd_mlb_key := get_current_model_ksuid(oodd_dir, oodd_version): @@ -356,12 +373,13 @@ def run_inference(self, detector_id: str, image_bytes: bytes, content_type: str, logger.info(f"Recent-average FPS for {detector_id=}: {fps:.2f}") return output_dict - def update_models_if_available(self, detector_id: str) -> bool: + def sync_models_from_cloud(self, detector_id: str) -> tuple[bool, bool]: """ - Request a new model from Groundlight. If there is a new model available for primary or OODD - inference, download it and write it to the model repository as a new version. + Fetch model metadata from Groundlight and save new primary/OODD binaries when available. - Returns True if a new model was downloaded and saved, False otherwise. + Returns ``(models_saved, minimal_compatible)`` where ``models_saved`` is whether anything + was downloaded and written to the model repository this cycle and ``minimal_compatible`` + is the cloud's latest assertion for the primary pipeline (always from this fetch). """ logger.debug(f"Checking if there are new models available for {detector_id}") @@ -373,11 +391,19 @@ def update_models_if_available(self, detector_id: str) -> bool: edge_model_info, oodd_model_info = fetch_model_info(detector_id, api_token=api_token) + # Decide OODD update from the just-fetched flag rather than the (possibly stale) DB row. + if INFERENCE_IMAGE_MODE == MODE_FULLY_MINIMAL: + run_separate_oodd = False + elif INFERENCE_IMAGE_MODE == MODE_STANDARD: + run_separate_oodd = True + else: + run_separate_oodd = not edge_model_info.minimal_compatible + primary_version = get_current_model_version(self.MODEL_REPOSITORY, detector_id) primary_edge_model_dir = get_primary_edge_model_dir(self.MODEL_REPOSITORY, detector_id) update_primary_model = should_update(edge_model_info, primary_edge_model_dir, primary_version) - if self.separate_oodd_inference: + if run_separate_oodd: oodd_version = get_current_model_version(self.MODEL_REPOSITORY, detector_id, is_oodd=True) oodd_model_dir = get_oodd_model_dir(self.MODEL_REPOSITORY, detector_id) update_oodd_model = should_update(oodd_model_info, oodd_model_dir, oodd_version) @@ -386,7 +412,7 @@ def update_models_if_available(self, detector_id: str) -> bool: if not update_primary_model and not update_oodd_model: logger.debug(f"No new models available for {detector_id}") - return False + return False, edge_model_info.minimal_compatible logger.info(f"At least one new model is available for {detector_id}, saving models to repository.") save_models_to_repository( @@ -397,7 +423,7 @@ def update_models_if_available(self, detector_id: str) -> bool: oodd_model_info=oodd_model_info if update_oodd_model else None, repository_root=self.MODEL_REPOSITORY, ) - return True + return True, edge_model_info.minimal_compatible @trace_span def escalation_cooldown_complete(self, detector_id: str, edge_config: EdgeEndpointConfig) -> bool: diff --git a/app/core/inference_image.py b/app/core/inference_image.py new file mode 100644 index 000000000..e44cf1108 --- /dev/null +++ b/app/core/inference_image.py @@ -0,0 +1,61 @@ +"""Per-detector inference image flavor selection. + +Three modes set globally via ``INFERENCE_IMAGE_MODE``. The concrete container image URIs +for each flavor are ``FULL_INFERENCE_IMAGE_URI`` and ``MINIMAL_INFERENCE_IMAGE_URI`` +(set by Helm from registry + tag): + +- ``standard``: every detector runs on the full image. +- ``minimal_if_compatible``: per-detector — minimal when the cloud reported + ``minimal_compatible=True``, full otherwise. +- ``fully_minimal``: every detector runs on the minimal image. + +``minimal_compatible`` is persisted on the primary ``InferenceDeployment`` row by +the model-updater after each fetch-model-urls cycle. The same selection is the +single signal for both the deployment image (minimal vs full) and whether to run +OODD as a separate pod (full → yes, minimal → folded into primary), so all +three call sites — model-updater, ``EdgeInferenceManager.run_inference``, and +deletion path — derive from this module and stay in agreement. +""" + +import logging +import os + +from app.core.database import DatabaseManager + +logger = logging.getLogger(__name__) + +MODE_STANDARD = "standard" +MODE_MINIMAL_IF_COMPATIBLE = "minimal_if_compatible" +MODE_FULLY_MINIMAL = "fully_minimal" + +VALID_MODES = {MODE_STANDARD, MODE_MINIMAL_IF_COMPATIBLE, MODE_FULLY_MINIMAL} + +INFERENCE_IMAGE_MODE = os.environ.get("INFERENCE_IMAGE_MODE", MODE_STANDARD) +if INFERENCE_IMAGE_MODE not in VALID_MODES: + raise ValueError( + f"INFERENCE_IMAGE_MODE={INFERENCE_IMAGE_MODE!r} is not one of {sorted(VALID_MODES)}. " + "The helm chart enforces this via values.schema.json; an invalid value here means the " + "env var was set manually outside of helm." + ) + +FULL_INFERENCE_IMAGE_URI = os.environ.get("FULL_INFERENCE_IMAGE_URI", "") +MINIMAL_INFERENCE_IMAGE_URI = os.environ.get("MINIMAL_INFERENCE_IMAGE_URI", "") + + +def detector_uses_minimal_image(detector_id: str, db_manager: DatabaseManager) -> bool: + """Whether ``detector_id`` should run on the minimal inference image.""" + if INFERENCE_IMAGE_MODE == MODE_FULLY_MINIMAL: + return True + if INFERENCE_IMAGE_MODE == MODE_STANDARD: + return False + record = db_manager.get_inference_deployment_record(detector_id, is_oodd=False) + return bool(record and record.minimal_compatible) + + +def detector_image(detector_id: str, db_manager: DatabaseManager) -> str: + """The fully-qualified inference image (incl. tag) for ``detector_id``.""" + return ( + MINIMAL_INFERENCE_IMAGE_URI + if detector_uses_minimal_image(detector_id, db_manager) + else FULL_INFERENCE_IMAGE_URI + ) diff --git a/app/core/kubernetes_management.py b/app/core/kubernetes_management.py index 54054a67a..0e231077d 100644 --- a/app/core/kubernetes_management.py +++ b/app/core/kubernetes_management.py @@ -8,15 +8,18 @@ from kubernetes import config from kubernetes.client import V1Deployment +from .database import DatabaseManager from .edge_inference import get_current_model_version from .file_paths import INFERENCE_DEPLOYMENT_TEMPLATE_PATH, KUBERNETES_NAMESPACE_PATH, MODEL_REPOSITORY_PATH +from .inference_image import detector_image from .naming import get_edge_inference_deployment_name, get_edge_inference_model_name, get_edge_inference_service_name logger = logging.getLogger(__name__) class InferenceDeploymentManager: - def __init__(self) -> None: + def __init__(self, db_manager: DatabaseManager) -> None: + self._db_manager = db_manager self._setup_kube_client() self._inference_deployment_template = self._load_inference_deployment_template() @@ -82,7 +85,7 @@ def _create_from_kube_manifest(self, namespace: str, manifest: str) -> None: else: raise e - def _substitute_placeholders(self, service_name: str, deployment_name: str, model_name: str) -> str: + def _substitute_placeholders(self, service_name: str, deployment_name: str, model_name: str, image: str) -> str: inference_deployment = self._inference_deployment_template inference_deployment = inference_deployment.replace("placeholder-inference-service-name", service_name) inference_deployment = inference_deployment.replace("placeholder-inference-deployment-name", deployment_name) @@ -92,6 +95,7 @@ def _substitute_placeholders(self, service_name: str, deployment_name: str, mode ) inference_deployment = inference_deployment.replace("placeholder-model-name", model_name) + inference_deployment = inference_deployment.replace("placeholder-inference-image", image) return inference_deployment.strip() def create_inference_deployment(self, detector_id: str, is_oodd: bool = False) -> None: @@ -110,8 +114,9 @@ def create_inference_deployment(self, detector_id: str, is_oodd: bool = False) - deployment_name = get_edge_inference_deployment_name(detector_id, is_oodd) service_name = get_edge_inference_service_name(detector_id, is_oodd) model_name = get_edge_inference_model_name(detector_id, is_oodd) + image = detector_image(detector_id, self._db_manager) inference_deployment = self._substitute_placeholders( - service_name=service_name, deployment_name=deployment_name, model_name=model_name + service_name=service_name, deployment_name=deployment_name, model_name=model_name, image=image ) model_version = get_current_model_version(MODEL_REPOSITORY_PATH, detector_id, is_oodd=is_oodd) @@ -145,6 +150,17 @@ def get_inference_deployment(self, deployment_name: str) -> V1Deployment | None: return None raise e + def get_deployment_image(self, deployment_name: str) -> str | None: + """Return the image (including tag) on the running Deployment, or None if the Deployment is absent.""" + deployment = self.get_inference_deployment(deployment_name) + if deployment is None: + return None + containers = deployment.spec.template.spec.containers + for c in containers: + if c.name == "inference-server": + return c.image + return containers[0].image if containers else None + def get_or_create_inference_deployment(self, detector_id: str, is_oodd: bool = False) -> V1Deployment | None: """ Retrieves an existing inference deployment for the specified detector ID, or creates a new diff --git a/app/core/models.py b/app/core/models.py index 855bf317b..4ef0f10f6 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -47,6 +47,15 @@ class InferenceDeployment(Base): nullable=False, comment="When True, the model-updater will delete this detector's K8s resources and then remove this row.", ) + minimal_compatible = Column( + Boolean, + nullable=True, + comment=( + "Cloud's assertion (from fetch-model-urls) that this detector's primary pipeline " + "can run on the minimal inference image. Only meaningful on the primary row " + "(is_oodd=False); NULL until the model-updater first writes it." + ), + ) created_at = Column( DateTime, nullable=True, default=datetime.datetime.utcnow, comment="Timestamp of record creation" diff --git a/app/core/utils.py b/app/core/utils.py index 87b13fca0..c4421d060 100644 --- a/app/core/utils.py +++ b/app/core/utils.py @@ -375,6 +375,7 @@ class ModelInfoBase(BaseModel): pipeline_config: str predictor_metadata: str + minimal_compatible: bool = False class ModelInfoNoBinary(ModelInfoBase): diff --git a/app/model_updater/update_models.py b/app/model_updater/update_models.py index 3c3329394..f422b6179 100644 --- a/app/model_updater/update_models.py +++ b/app/model_updater/update_models.py @@ -5,6 +5,12 @@ from app.core.database import DatabaseManager from app.core.edge_config_manager import EdgeConfigManager from app.core.edge_inference import EdgeInferenceManager, delete_old_model_versions +from app.core.inference_image import ( + INFERENCE_IMAGE_MODE, + MODE_MINIMAL_IF_COMPATIBLE, + detector_image, + detector_uses_minimal_image, +) from app.core.kubernetes_management import InferenceDeploymentManager from app.core.naming import get_edge_inference_deployment_name, get_edge_inference_model_name @@ -22,8 +28,6 @@ # second rollout on top of the still-loading first one (memory doubles → eviction cascade). ROLLOUT_READY_TIMEOUT_S = int(os.environ.get("ROLLOUT_READY_TIMEOUT_S", 60 * 30)) -USE_MINIMAL_IMAGE = os.environ.get("USE_MINIMAL_IMAGE", "false") == "true" - def sleep_forever(message: str | None = None): while True: @@ -31,12 +35,77 @@ def sleep_forever(message: str | None = None): time.sleep(TEN_MINUTES) +def _persist_minimal_compatible(db_manager: DatabaseManager, detector_id: str, minimal_compatible: bool) -> bool: + """Write minimal_compatible onto the primary DB row when it has changed. + + Returns True when the effective value changed (NULL is treated as False, matching + ``detector_uses_minimal_image``). + """ + record = db_manager.get_inference_deployment_record(detector_id, is_oodd=False) + if record is None: + return False + previous_effective = bool(record.minimal_compatible) + if record.minimal_compatible == minimal_compatible: + return False + primary_model_name = get_edge_inference_model_name(detector_id, is_oodd=False) + db_manager.update_inference_deployment_record( + model_name=primary_model_name, + fields_to_update={"minimal_compatible": minimal_compatible}, + ) + return previous_effective != minimal_compatible + + +def _redeploy_for_flavor_swap( + detector_id: str, + desired_image: str, + desired_separate_oodd: bool, + deployment_manager: InferenceDeploymentManager, +) -> bool: + """ + Tear down both primary and OODD deployments and recreate them in the desired flavor. There will + be downtime during the swap. + + Deployment names are derived only from ``detector_id``, so primary-minimal and primary-full + collide on name — surge update is impossible. Always delete-then-create; if we crash between + the two, the next cycle re-deletes (404-tolerant) and recreates fresh, so there's no + partial-state recovery needed. + + Returns True when delete and recreate both succeed. Otherwise, returns False. + """ + logger.warning( + f"Flavor swap for {detector_id}: redeploying to {desired_image} " + f"(separate_oodd={desired_separate_oodd}). Tearing down primary+OODD pods." + ) + + deployment_manager.delete_inference_deployment(detector_id, is_oodd=False) + deployment_manager.delete_inference_deployment(detector_id, is_oodd=True) + + poll_start = time.time() + while time.time() - poll_start < POD_DELETION_TIMEOUT_SECONDS: + primary_gone = deployment_manager.is_inference_deployment_fully_deleted(detector_id, is_oodd=False) + oodd_gone = deployment_manager.is_inference_deployment_fully_deleted(detector_id, is_oodd=True) + if primary_gone and oodd_gone: + break + time.sleep(2) + else: + logger.error( + f"Timed out waiting for {detector_id} pods to terminate during flavor swap. " "Next cycle will retry." + ) + return False + + logger.info(f"Flavor swap for {detector_id}: creating primary on {desired_image}") + deployment_manager.create_inference_deployment(detector_id=detector_id, is_oodd=False) + if desired_separate_oodd: + logger.info(f"Flavor swap for {detector_id}: creating OODD on {desired_image}") + deployment_manager.create_inference_deployment(detector_id=detector_id, is_oodd=True) + return True + + def _check_new_models_and_inference_deployments( detector_id: str, edge_inference_manager: EdgeInferenceManager, deployment_manager: InferenceDeploymentManager, db_manager: DatabaseManager, - separate_oodd_inference: bool, ) -> None: """ Check if there are new models available for the detector_id. If so, update the inference deployment @@ -44,29 +113,48 @@ def _check_new_models_and_inference_deployments( and updating the database record for the detector_id (i.e., setting deployment_created to True when we have successfully rolled out the inference deployment). - :param detector_id: the detector_id for which we are checking for new models and inference deployments. - :param edge_inference_manager: the edge inference manager object. - :param deployment_manager: the inference deployment manager object. - :param db_manager: the database manager object. - :param separate_oodd_inference: whether or not to run inference separately for an OODD model + The per-detector image flavor (full vs minimal) is read from app.core.inference_image so that + the deployment we create, the OODD-creation decision, and the request-path routing all derive + from the same source. """ # Download and write new model to model repo on disk - new_model = edge_inference_manager.update_models_if_available(detector_id=detector_id) + new_model, minimal_compatible = edge_inference_manager.sync_models_from_cloud(detector_id=detector_id) + + # Persist minimal_compatible before any read-back through inference_image so the per-detector + # flavor lookup sees the latest value within this same iteration. + minimal_compatible_flipped = _persist_minimal_compatible(db_manager, detector_id, minimal_compatible) + + separate_oodd = not detector_uses_minimal_image(detector_id, db_manager) + desired_image = detector_image(detector_id, db_manager) edge_deployment_name = get_edge_inference_deployment_name(detector_id) oodd_deployment_name = get_edge_inference_deployment_name(detector_id, is_oodd=True) - deployment_names = ( - f"{edge_deployment_name} and {oodd_deployment_name}" if separate_oodd_inference else edge_deployment_name - ) + deployment_names = f"{edge_deployment_name} and {oodd_deployment_name}" if separate_oodd else edge_deployment_name + + # Reconcile running deployments with desired image and separate-OODD layout. A minimal_compatible + # flip triggers delete-and-recreate only in minimal_if_compatible mode (the only mode where the + # flag changes deployment behavior). We also redeploy on image mismatch or stale OODD presence. + primary_exists = deployment_manager.get_inference_deployment(edge_deployment_name) is not None + oodd_exists = deployment_manager.get_inference_deployment(oodd_deployment_name) is not None + observed_image = deployment_manager.get_deployment_image(edge_deployment_name) + image_mismatch = observed_image is not None and observed_image != desired_image + separate_oodd_mismatch = separate_oodd != oodd_exists + flip_affects_deployments = minimal_compatible_flipped and INFERENCE_IMAGE_MODE == MODE_MINIMAL_IF_COMPATIBLE + swap_happened = False + + if flip_affects_deployments and (primary_exists or oodd_exists): + swap_happened = _redeploy_for_flavor_swap(detector_id, desired_image, separate_oodd, deployment_manager) + elif primary_exists and (image_mismatch or separate_oodd_mismatch): + swap_happened = _redeploy_for_flavor_swap(detector_id, desired_image, separate_oodd, deployment_manager) edge_deployment = deployment_manager.get_inference_deployment(deployment_name=edge_deployment_name) - deployment_created = False + deployment_created = swap_happened if edge_deployment is None: logger.info(f"Creating a new edge inference deployment for {detector_id}") deployment_manager.create_inference_deployment(detector_id=detector_id) deployment_created = True - if separate_oodd_inference: + if separate_oodd: oodd_deployment = deployment_manager.get_inference_deployment(deployment_name=oodd_deployment_name) if oodd_deployment is None: logger.info(f"Creating a new oodd inference deployment for {detector_id}") @@ -81,7 +169,7 @@ def _check_new_models_and_inference_deployments( # Update inference deployment and rollout a new pod logger.info(f"Updating inference deployment for {detector_id}") deployment_manager.update_inference_deployment(detector_id=detector_id) - if separate_oodd_inference: + if separate_oodd: deployment_manager.update_inference_deployment(detector_id=detector_id, is_oodd=True) # Poll until the deployment rollout begins @@ -90,7 +178,7 @@ def _check_new_models_and_inference_deployments( rollout_start_timeout = 10 poll_start = time.time() while deployment_manager.is_inference_deployment_rollout_complete(deployment_name=edge_deployment_name) or ( - separate_oodd_inference + separate_oodd and deployment_manager.is_inference_deployment_rollout_complete(deployment_name=oodd_deployment_name) ): time.sleep(0.5) @@ -101,7 +189,7 @@ def _check_new_models_and_inference_deployments( logger.info(f"Waiting for inference deployment(s) ({deployment_names}) to complete") poll_start = time.time() while not deployment_manager.is_inference_deployment_rollout_complete(deployment_name=edge_deployment_name) or ( - separate_oodd_inference + separate_oodd and not deployment_manager.is_inference_deployment_rollout_complete(deployment_name=oodd_deployment_name) ): time.sleep(5) @@ -118,7 +206,7 @@ def _check_new_models_and_inference_deployments( delete_old_model_versions(detector_id, repository_root=edge_inference_manager.MODEL_REPOSITORY, num_to_keep=2) if deployment_manager.is_inference_deployment_rollout_complete(deployment_name=edge_deployment_name) and ( - not separate_oodd_inference + not separate_oodd or deployment_manager.is_inference_deployment_rollout_complete(deployment_name=oodd_deployment_name) ): # Database transaction to update the deployment_created field for the detector_id @@ -129,7 +217,7 @@ def _check_new_models_and_inference_deployments( model_name=primary_model_name, fields_to_update={"deployment_created": True, "deployment_name": edge_deployment_name}, ) - if separate_oodd_inference: + if separate_oodd: oodd_model_name = get_edge_inference_model_name(detector_id, is_oodd=True) db_manager.update_inference_deployment_record( model_name=oodd_model_name, @@ -141,7 +229,6 @@ def manage_update_models( edge_inference_manager: EdgeInferenceManager, deployment_manager: InferenceDeploymentManager, db_manager: DatabaseManager, - separate_oodd_inference: bool, ) -> None: """ Periodically update inference models for detectors. @@ -156,11 +243,6 @@ def manage_update_models( NOTE: The periodicity of this task is controlled by refresh_rate in the active edge config file. The value is re-read each cycle so it can be changed at runtime. - - :param edge_inference_manager: the edge inference manager object. - :param deployment_manager: the inference deployment manager object. - :param db_manager: the database manager object. - :param separate_oodd_inference: whether to run inference separately for an OODD model. """ deploy_detector_level_inference = bool(int(os.environ.get("DEPLOY_DETECTOR_LEVEL_INFERENCE", 0))) if not deploy_detector_level_inference: @@ -173,9 +255,10 @@ def manage_update_models( if pending_deletions: logger.info(f"Processing deletion of {len(pending_deletions)} detector(s): {pending_deletions}") for detector_id in pending_deletions: + # Unconditionally delete both primary and OODD: delete_inference_deployment + # tolerates 404, so we don't need to remember the prior per-detector flavor. deployment_manager.delete_inference_deployment(detector_id) - if separate_oodd_inference: - deployment_manager.delete_inference_deployment(detector_id, is_oodd=True) + deployment_manager.delete_inference_deployment(detector_id, is_oodd=True) # Poll until all pods are fully terminated poll_start = time.time() @@ -183,10 +266,7 @@ def manage_update_models( while time.time() - poll_start < POD_DELETION_TIMEOUT_SECONDS: all_gone = all( deployment_manager.is_inference_deployment_fully_deleted(did) - and ( - not separate_oodd_inference - or deployment_manager.is_inference_deployment_fully_deleted(did, is_oodd=True) - ) + and deployment_manager.is_inference_deployment_fully_deleted(did, is_oodd=True) for did in pending_deletions ) if all_gone: @@ -213,7 +293,6 @@ def manage_update_models( edge_inference_manager=edge_inference_manager, deployment_manager=deployment_manager, db_manager=db_manager, - separate_oodd_inference=separate_oodd_inference, ) logger.debug(f"Successfully updated model for detector_id: {detector_id}") except Exception as e: @@ -231,6 +310,7 @@ def manage_update_models( deployment_records = db_manager.get_inference_deployment_records() deployed_detector_ids = set(record.detector_id for record in deployment_records) for detector_id in deployed_detector_ids: + separate_oodd = not detector_uses_minimal_image(detector_id, db_manager) primary_deployment_name = get_edge_inference_deployment_name(detector_id) primary_deployment_created = ( deployment_manager.get_inference_deployment(primary_deployment_name) is not None @@ -240,7 +320,7 @@ def manage_update_models( fields_to_update={"deployment_created": primary_deployment_created}, ) - if separate_oodd_inference: + if separate_oodd: oodd_deployment_name = get_edge_inference_deployment_name(detector_id, is_oodd=True) oodd_deployment_created = deployment_manager.get_inference_deployment(oodd_deployment_name) is not None db_manager.update_inference_deployment_record( @@ -253,16 +333,14 @@ def manage_update_models( logger.info("Starting model updater.") logger.info("Creating edge inference manager, deployment manager, and database manager.") - edge_inference_manager = EdgeInferenceManager(verbose=True) - deployment_manager = InferenceDeploymentManager() - # We will delegate creation of database tables to the edge-endpoint container. # So here we don't run a task to create the tables if they don't already exist. db_manager = DatabaseManager() + edge_inference_manager = EdgeInferenceManager(db_manager=db_manager, verbose=True) + deployment_manager = InferenceDeploymentManager(db_manager=db_manager) manage_update_models( edge_inference_manager=edge_inference_manager, deployment_manager=deployment_manager, db_manager=db_manager, - separate_oodd_inference=not USE_MINIMAL_IMAGE, ) diff --git a/deploy/helm/groundlight-edge-endpoint/files/inference-deployment-template.yaml b/deploy/helm/groundlight-edge-endpoint/files/inference-deployment-template.yaml index a70bd4d51..f816be13b 100644 --- a/deploy/helm/groundlight-edge-endpoint/files/inference-deployment-template.yaml +++ b/deploy/helm/groundlight-edge-endpoint/files/inference-deployment-template.yaml @@ -48,7 +48,7 @@ spec: containers: - name: inference-server - image: 767397850842.dkr.ecr.us-west-2.amazonaws.com/gl-edge-inference{{ if .Values.useMinimalImage }}-minimal{{ end }}:{{ include "groundlight-edge-endpoint.inferenceTag" . }} + image: placeholder-inference-image imagePullPolicy: "{{ include "groundlight-edge-endpoint.inferencePullPolicy" . }}" env: - name: MODEL_REPOSITORY diff --git a/deploy/helm/groundlight-edge-endpoint/templates/_helpers.tpl b/deploy/helm/groundlight-edge-endpoint/templates/_helpers.tpl index 6bf2be9e8..f1d6d9dce 100644 --- a/deploy/helm/groundlight-edge-endpoint/templates/_helpers.tpl +++ b/deploy/helm/groundlight-edge-endpoint/templates/_helpers.tpl @@ -102,6 +102,25 @@ Never {{- end -}} {{- end -}} +{{/* + Resolve the per-install inference image mode (standard / minimal_if_compatible / fully_minimal). +*/}} +{{- define "groundlight-edge-endpoint.inferenceImageMode" -}} +{{- default "standard" .Values.inferenceImageMode -}} +{{- end -}} + +{{/* + Compute the full ECR image URI (incl. tag) for the inference server, picking the + full vs minimal repository based on the boolean argument. +*/}} +{{- define "groundlight-edge-endpoint.inferenceImageFull" -}} +{{- printf "%s/gl-edge-inference:%s" .Values.ecrRegistry (include "groundlight-edge-endpoint.inferenceTag" .) -}} +{{- end -}} + +{{- define "groundlight-edge-endpoint.inferenceImageMinimal" -}} +{{- printf "%s/gl-edge-inference-minimal:%s" .Values.ecrRegistry (include "groundlight-edge-endpoint.inferenceTag" .) -}} +{{- end -}} + {{- define "groundlight-edge-endpoint.inferencePullPolicy" -}} {{- $tag := include "groundlight-edge-endpoint.inferenceTag" . -}} {{- if eq $tag "dev" -}} diff --git a/deploy/helm/groundlight-edge-endpoint/templates/edge-deployment.yaml b/deploy/helm/groundlight-edge-endpoint/templates/edge-deployment.yaml index a02828ef5..8aa6af0de 100644 --- a/deploy/helm/groundlight-edge-endpoint/templates/edge-deployment.yaml +++ b/deploy/helm/groundlight-edge-endpoint/templates/edge-deployment.yaml @@ -193,8 +193,12 @@ spec: value: "1" - name: GROUNDLIGHT_ENDPOINT value: "{{ .Values.upstreamEndpoint }}" - - name: USE_MINIMAL_IMAGE - value: "{{ .Values.useMinimalImage }}" + - name: INFERENCE_IMAGE_MODE + value: "{{ include "groundlight-edge-endpoint.inferenceImageMode" . }}" + - name: FULL_INFERENCE_IMAGE_URI + value: "{{ include "groundlight-edge-endpoint.inferenceImageFull" . }}" + - name: MINIMAL_INFERENCE_IMAGE_URI + value: "{{ include "groundlight-edge-endpoint.inferenceImageMinimal" . }}" - name: ENABLE_PROFILING value: "{{ .Values.enableProfiling }}" volumeMounts: @@ -306,8 +310,12 @@ spec: secretKeyRef: name: groundlight-api-token key: GROUNDLIGHT_API_TOKEN - - name: USE_MINIMAL_IMAGE - value: "{{ .Values.useMinimalImage }}" + - name: INFERENCE_IMAGE_MODE + value: "{{ include "groundlight-edge-endpoint.inferenceImageMode" . }}" + - name: FULL_INFERENCE_IMAGE_URI + value: "{{ include "groundlight-edge-endpoint.inferenceImageFull" . }}" + - name: MINIMAL_INFERENCE_IMAGE_URI + value: "{{ include "groundlight-edge-endpoint.inferenceImageMinimal" . }}" - name: ROLLOUT_READY_TIMEOUT_S value: "{{ .Values.modelUpdater.rolloutReadyTimeoutSeconds }}" volumeMounts: diff --git a/deploy/helm/groundlight-edge-endpoint/values.schema.json b/deploy/helm/groundlight-edge-endpoint/values.schema.json new file mode 100644 index 000000000..5212af9e9 --- /dev/null +++ b/deploy/helm/groundlight-edge-endpoint/values.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "inferenceImageMode": { + "type": "string", + "enum": ["standard", "minimal_if_compatible", "fully_minimal"], + "description": "Per-detector inference image selection mode. See values.yaml for full descriptions." + } + } +} diff --git a/deploy/helm/groundlight-edge-endpoint/values.yaml b/deploy/helm/groundlight-edge-endpoint/values.yaml index cd51cffaf..efa9574c4 100644 --- a/deploy/helm/groundlight-edge-endpoint/values.yaml +++ b/deploy/helm/groundlight-edge-endpoint/values.yaml @@ -19,10 +19,15 @@ imageTag: "release" edgeEndpointTag: "" inferenceTag: "" -# Whether to use the minimal image for the inference server. Currently, the minimal image only -# supports binary and multiclass detectors, so be sure to keep this set to false if you're using -# an object detection or counting model. -useMinimalImage: false +# Per-detector inference image flavor. +# - "standard": every detector runs on the full inference image. +# - "minimal_if_compatible": each detector runs on the minimal image when the cloud reports +# its primary pipeline as minimal-compatible, and the full image otherwise. The minimal +# image folds OODD into the primary pipeline (no separate OODD pod); the full image +# runs OODD as a separate pod. +# - "fully_minimal": every detector runs on the minimal image. Will only successfully deploy +# pipelines that are supported by the minimal image. +inferenceImageMode: "standard" # Enable request-level tracing profiling for the edge endpoint. # When enabled, per-request trace data (span timings, annotations) is written to diff --git a/test/core/test_inference_image.py b/test/core/test_inference_image.py new file mode 100644 index 000000000..210702302 --- /dev/null +++ b/test/core/test_inference_image.py @@ -0,0 +1,90 @@ +"""Selection matrix for the per-detector image-flavor decision. + +Covers 3 modes: {minimal_compatible=True, False, missing DB row}. Each cell asserts both +``detector_image(...)`` and ``detector_uses_minimal_image(...)`` since both go through the +same primitive — the two getters are required to agree. +""" + +from unittest import mock + +import pytest + +from app.core import inference_image + +FULL = "ecr/gl-edge-inference:tag" +MINIMAL = "ecr/gl-edge-inference-minimal:tag" + + +def _db_with(minimal_compatible): + """Build a DB stub whose primary record reports the given minimal_compatible value. + + Pass ``None`` to model the missing-row case (record is None). + """ + db = mock.Mock() + if minimal_compatible is None: + db.get_inference_deployment_record.return_value = None + else: + record = mock.Mock() + record.minimal_compatible = minimal_compatible + db.get_inference_deployment_record.return_value = record + return db + + +@pytest.fixture(autouse=True) +def _patch_image_uris(): + """Pin the full/minimal URIs to known values regardless of import-time env.""" + with ( + mock.patch.object(inference_image, "FULL_INFERENCE_IMAGE_URI", FULL), + mock.patch.object(inference_image, "MINIMAL_INFERENCE_IMAGE_URI", MINIMAL), + ): + yield + + +@pytest.mark.parametrize("minimal_compatible", [True, False, None]) +def test_standard_mode_always_full(minimal_compatible): + """``standard`` ignores per-row state and always picks the full image.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "standard"): + db = _db_with(minimal_compatible) + assert inference_image.detector_uses_minimal_image("det", db) is False + assert inference_image.detector_image("det", db) == FULL + + +@pytest.mark.parametrize("minimal_compatible", [True, False, None]) +def test_fully_minimal_mode_always_minimal(minimal_compatible): + """``fully_minimal`` ignores per-row state and always picks the minimal image.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "fully_minimal"): + db = _db_with(minimal_compatible) + assert inference_image.detector_uses_minimal_image("det", db) is True + assert inference_image.detector_image("det", db) == MINIMAL + + +def test_minimal_if_compatible_with_compatible_row(): + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _db_with(True) + assert inference_image.detector_uses_minimal_image("det", db) is True + assert inference_image.detector_image("det", db) == MINIMAL + + +def test_minimal_if_compatible_with_incompatible_row(): + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _db_with(False) + assert inference_image.detector_uses_minimal_image("det", db) is False + assert inference_image.detector_image("det", db) == FULL + + +def test_minimal_if_compatible_missing_row_defaults_full(): + """Missing DB row → False (safe default; degrades gracefully into standard for that detector).""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _db_with(None) + assert inference_image.detector_uses_minimal_image("det", db) is False + assert inference_image.detector_image("det", db) == FULL + + +def test_minimal_if_compatible_null_minimal_compatible_defaults_full(): + """Row exists but minimal_compatible is NULL (not yet written by the model-updater) → False.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db2 = mock.Mock() + record = mock.Mock() + record.minimal_compatible = None + db2.get_inference_deployment_record.return_value = record + assert inference_image.detector_uses_minimal_image("det", db2) is False diff --git a/test/core/test_inference_image_agreement.py b/test/core/test_inference_image_agreement.py new file mode 100644 index 000000000..00bd069c3 --- /dev/null +++ b/test/core/test_inference_image_agreement.py @@ -0,0 +1,122 @@ +"""Cross-site agreement test: every site that consults the per-detector flavor must agree. + +The minimal-image deployment folds OODD into the primary pipeline; the full-image deployment +runs OODD as a separate pod. A drift between (image we deploy ↔ OODD topology we expect ↔ +request-path routing) would leave the request path looking for an OODD service that the +model-updater never created, or vice versa. This test asserts all three derive from the same +``detector_uses_minimal_image`` answer, including the K8s deployment path which is exercised +by calling the real ``create_inference_deployment`` with I/O mocked out. +""" + +from unittest import mock + +import pytest + +import app.core.edge_inference as ei_mod +from app.core import inference_image +from app.core.edge_inference import EdgeInferenceManager +from app.core.kubernetes_management import InferenceDeploymentManager + + +@pytest.fixture(autouse=True) +def clear_oodd_cache(): + ei_mod._separate_oodd_cache.clear() + yield + ei_mod._separate_oodd_cache.clear() + + +def _db_with(minimal_compatible): + db = mock.Mock() + record = mock.Mock() + record.minimal_compatible = minimal_compatible + db.get_inference_deployment_record.return_value = record + return db + + +def test_all_sites_agree_for_minimal_compatible_detector(): + with ( + mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"), + mock.patch.object(inference_image, "MINIMAL_INFERENCE_IMAGE_URI", "ecr/minimal:tag"), + mock.patch.object(inference_image, "FULL_INFERENCE_IMAGE_URI", "ecr/full:tag"), + ): + + db = _db_with(minimal_compatible=True) + + # 1. The deployment image picked by the K8s manager + image = inference_image.detector_image("det", db) + + # 2. The OODD-creation decision in the model updater + deploy_separate_oodd = not inference_image.detector_uses_minimal_image("det", db) + + # 3. The routing decision in EdgeInferenceManager + eim = EdgeInferenceManager(db_manager=db) + request_separate_oodd = eim.uses_separate_oodd("det") + + assert image == "ecr/minimal:tag" + assert deploy_separate_oodd is False + assert request_separate_oodd is False + assert deploy_separate_oodd == request_separate_oodd + + +def test_all_sites_agree_for_incompatible_detector(): + with ( + mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"), + mock.patch.object(inference_image, "MINIMAL_INFERENCE_IMAGE_URI", "ecr/minimal:tag"), + mock.patch.object(inference_image, "FULL_INFERENCE_IMAGE_URI", "ecr/full:tag"), + ): + + db = _db_with(minimal_compatible=False) + + image = inference_image.detector_image("det", db) + deploy_separate_oodd = not inference_image.detector_uses_minimal_image("det", db) + eim = EdgeInferenceManager(db_manager=db) + request_separate_oodd = eim.uses_separate_oodd("det") + + assert image == "ecr/full:tag" + assert deploy_separate_oodd is True + assert request_separate_oodd is True + + +_MINIMAL_DEPLOYMENT_YAML = """\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: placeholder-inference-deployment-name + labels: + app: placeholder-inference-instance-name +spec: + selector: + matchLabels: + app: placeholder-inference-instance-name + template: + metadata: {} + spec: + containers: + - name: main + image: placeholder-inference-image +""" + + +def test_k8s_deployment_path_uses_detector_image_primitive(): + """create_inference_deployment derives its image from the same detector_image() primitive as the other sites.""" + with ( + mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"), + mock.patch.object(inference_image, "MINIMAL_INFERENCE_IMAGE_URI", "ecr/minimal:tag"), + mock.patch.object(inference_image, "FULL_INFERENCE_IMAGE_URI", "ecr/full:tag"), + mock.patch.object(InferenceDeploymentManager, "_setup_kube_client"), + mock.patch.object( + InferenceDeploymentManager, + "_load_inference_deployment_template", + return_value=_MINIMAL_DEPLOYMENT_YAML, + ), + mock.patch.object(InferenceDeploymentManager, "_create_from_kube_manifest") as mock_create, + mock.patch("app.core.kubernetes_management.get_current_model_version", return_value=1), + ): + db = _db_with(minimal_compatible=True) + idm = InferenceDeploymentManager(db_manager=db) + idm._target_namespace = "test-ns" + + idm.create_inference_deployment("det") + + manifest = mock_create.call_args.kwargs["manifest"] + assert "ecr/minimal:tag" in manifest diff --git a/test/core/test_kubernetes_management.py b/test/core/test_kubernetes_management.py index ba53e1eb7..286e114d0 100644 --- a/test/core/test_kubernetes_management.py +++ b/test/core/test_kubernetes_management.py @@ -7,6 +7,7 @@ def _make_manager(): from app.core.kubernetes_management import InferenceDeploymentManager mgr = InferenceDeploymentManager() + mgr._db_manager = MagicMock() mgr._core_kube_client = MagicMock() mgr._app_kube_client = MagicMock() mgr._target_namespace = "edge" @@ -60,3 +61,34 @@ def test_incomplete_when_terminating_pod_lingers(self): mgr.get_inference_deployment = MagicMock(return_value=_make_deployment()) mgr._core_kube_client.list_namespaced_pod.return_value = _make_pod_list(2) assert mgr.is_inference_deployment_rollout_complete("test-dep") is False + + +class TestSubstitutePlaceholders: + def test_image_placeholder_replaced(self): + """The placeholder-inference-image string is replaced with the per-detector image URI.""" + mgr = _make_manager() + mgr._inference_deployment_template = ( + "kind: Deployment\nspec:\n containers:\n - image: placeholder-inference-image\n" + ) + out = mgr._substitute_placeholders( + service_name="svc", deployment_name="dep", model_name="det/primary", image="ecr/full:tag" + ) + assert "placeholder-inference-image" not in out + assert "ecr/full:tag" in out + + +class TestGetDeploymentImage: + def test_returns_inference_server_image(self): + mgr = _make_manager() + container = MagicMock() + container.name = "inference-server" + container.image = "ecr/full:tag" + dep = MagicMock() + dep.spec.template.spec.containers = [container] + mgr.get_inference_deployment = MagicMock(return_value=dep) + assert mgr.get_deployment_image("dep") == "ecr/full:tag" + + def test_returns_none_when_missing(self): + mgr = _make_manager() + mgr.get_inference_deployment = MagicMock(return_value=None) + assert mgr.get_deployment_image("dep") is None diff --git a/test/core/test_utils.py b/test/core/test_utils.py new file mode 100644 index 000000000..be3073e2e --- /dev/null +++ b/test/core/test_utils.py @@ -0,0 +1,34 @@ +from app.core.utils import ModelInfoWithBinary, parse_model_info + + +def _base_response(**overrides) -> dict: + response = { + "pipeline_config": "primary_pipeline_config", + "predictor_metadata": '{"text_query":"x","mode":"BINARY"}', + "model_binary_id": "primary_binary_id", + "model_binary_url": "https://example/primary", + "oodd_pipeline_config": "oodd_pipeline_config", + "oodd_model_binary_id": "oodd_binary_id", + "oodd_model_binary_url": "https://example/oodd", + } + response.update(overrides) + return response + + +class TestParseModelInfo: + def test_minimal_compatible_true(self): + edge_info, oodd_info = parse_model_info(_base_response(minimal_compatible=True)) + assert isinstance(edge_info, ModelInfoWithBinary) + assert edge_info.minimal_compatible is True + assert oodd_info.minimal_compatible is False + + def test_minimal_compatible_false(self): + edge_info, oodd_info = parse_model_info(_base_response(minimal_compatible=False)) + assert edge_info.minimal_compatible is False + assert oodd_info.minimal_compatible is False + + def test_minimal_compatible_missing_defaults_false(self): + """When the cloud is older than the edge endpoint, the field is absent.""" + edge_info, oodd_info = parse_model_info(_base_response()) + assert edge_info.minimal_compatible is False + assert oodd_info.minimal_compatible is False diff --git a/test/edge_inference/test_edge_inference_manager.py b/test/edge_inference/test_edge_inference_manager.py index 8f674a503..4c5671323 100644 --- a/test/edge_inference/test_edge_inference_manager.py +++ b/test/edge_inference/test_edge_inference_manager.py @@ -11,6 +11,15 @@ from app.core.utils import ModelInfoBase, ModelInfoNoBinary, ModelInfoWithBinary +def _fake_db(minimal_compatible: bool = False): + """Build a DatabaseManager stub that returns a record with the given minimal_compatible flag.""" + db = mock.Mock() + record = mock.Mock() + record.minimal_compatible = minimal_compatible + db.get_inference_deployment_record.return_value = record + return db + + def validate_model_directory( model_repository: str, detector_id: str, version: int, model_info: ModelInfoBase, is_oodd: bool = False ): @@ -99,16 +108,24 @@ def oodd_model_info_no_binary() -> ModelInfoNoBinary: class TestEdgeInferenceManager: + @pytest.fixture(autouse=True) + def clear_oodd_cache(self): + import app.core.edge_inference as ei_mod + + ei_mod._separate_oodd_cache.clear() + yield + ei_mod._separate_oodd_cache.clear() + def test_update_model_with_binary(self, edge_model_info_with_binary, oodd_model_info_with_binary): with tempfile.TemporaryDirectory() as temp_dir: with mock.patch("app.core.edge_inference.fetch_model_info") as mock_fetch: with mock.patch("app.core.edge_inference.get_object_using_presigned_url") as mock_get_from_s3: mock_get_from_s3.return_value = b"test_model" mock_fetch.return_value = (edge_model_info_with_binary, oodd_model_info_with_binary) - edge_manager = EdgeInferenceManager() + edge_manager = EdgeInferenceManager(db_manager=_fake_db()) edge_manager.MODEL_REPOSITORY = temp_dir # type: ignore detector_id = "test_detector" - edge_manager.update_models_if_available(detector_id) + edge_manager.sync_models_from_cloud(detector_id) validate_model_directory(temp_dir, detector_id, 1, edge_model_info_with_binary) validate_model_directory(temp_dir, detector_id, 1, oodd_model_info_with_binary, is_oodd=True) @@ -122,13 +139,13 @@ def test_update_model_with_binary(self, edge_model_info_with_binary, oodd_model_ oodd_model_info_with_binary_2.model_binary_id = "test_oodd_binary_id_2" oodd_model_info_with_binary_2.model_binary_url = "test_oodd_model_binary_url_2" mock_fetch.return_value = (edge_model_info_with_binary_2, oodd_model_info_with_binary_2) - edge_manager.update_models_if_available(detector_id) + edge_manager.sync_models_from_cloud(detector_id) validate_model_directory(temp_dir, detector_id, 2, edge_model_info_with_binary_2) validate_model_directory(temp_dir, detector_id, 2, oodd_model_info_with_binary_2, is_oodd=True) with mock.patch("app.core.edge_inference.get_object_using_presigned_url") as mock_get_from_s3: - edge_manager.update_models_if_available(detector_id) + edge_manager.sync_models_from_cloud(detector_id) # Shouldn't pull a model from s3 if there is no new binary available mock_get_from_s3.assert_not_called() # Should not create a new version for the same model info @@ -139,10 +156,10 @@ def test_update_model_no_binary(self, edge_model_info_no_binary, oodd_model_info with tempfile.TemporaryDirectory() as temp_dir: with mock.patch("app.core.edge_inference.fetch_model_info") as mock_fetch: mock_fetch.return_value = (edge_model_info_no_binary, oodd_model_info_no_binary) - edge_manager = EdgeInferenceManager() + edge_manager = EdgeInferenceManager(db_manager=_fake_db()) edge_manager.MODEL_REPOSITORY = temp_dir # type: ignore detector_id = "test_detector" - edge_manager.update_models_if_available(detector_id) + edge_manager.sync_models_from_cloud(detector_id) validate_model_directory(temp_dir, detector_id, 1, edge_model_info_no_binary) validate_model_directory(temp_dir, detector_id, 1, oodd_model_info_no_binary, is_oodd=True) @@ -153,12 +170,12 @@ def test_update_model_no_binary(self, edge_model_info_no_binary, oodd_model_info oodd_model_info_no_binary_2 = oodd_model_info_no_binary oodd_model_info_no_binary_2.pipeline_config = "test_oodd_pipeline_config_2" mock_fetch.return_value = (edge_model_info_no_binary_2, oodd_model_info_no_binary_2) - edge_manager.update_models_if_available(detector_id) + edge_manager.sync_models_from_cloud(detector_id) validate_model_directory(temp_dir, detector_id, 2, edge_model_info_no_binary_2) validate_model_directory(temp_dir, detector_id, 2, oodd_model_info_no_binary_2, is_oodd=True) - edge_manager.update_models_if_available(detector_id) + edge_manager.sync_models_from_cloud(detector_id) # Should not create a new version for the same pipeline config assert not os.path.exists(os.path.join(temp_dir, detector_id, "primary", "3")) assert not os.path.exists(os.path.join(temp_dir, detector_id, "oodd", "3")) @@ -173,7 +190,7 @@ def test_run_inference_with_oodd(self): with mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit: mock_submit.return_value = mock_response # separate_oodd_inference is True by default - edge_manager = EdgeInferenceManager() + edge_manager = EdgeInferenceManager(db_manager=_fake_db()) edge_manager.run_inference("test_detector", b"test_image", "image/jpeg", mode=ModeEnum.BINARY) primary_inference_client_url = get_edge_inference_service_name("test_detector") + ":8000" oodd_inference_client_url = get_edge_inference_service_name("test_detector", is_oodd=True) + ":8000" @@ -194,9 +211,12 @@ def test_run_inference_without_oodd(self): "secondary_predictions": None, } - with mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit: + with ( + mock.patch("app.core.inference_image.INFERENCE_IMAGE_MODE", "minimal_if_compatible"), + mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit, + ): mock_submit.return_value = mock_response - edge_manager = EdgeInferenceManager(separate_oodd_inference=False) + edge_manager = EdgeInferenceManager(db_manager=_fake_db(minimal_compatible=True)) edge_manager.run_inference("test_detector", b"test_image", "image/jpeg", mode=ModeEnum.BINARY) primary_inference_client_url = get_edge_inference_service_name("test_detector") + ":8000" @@ -226,7 +246,7 @@ def test_run_inference_stamps_mlb_keys(self): with mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit: mock_submit.return_value = mock_response - edge_manager = EdgeInferenceManager() + edge_manager = EdgeInferenceManager(db_manager=_fake_db()) edge_manager.MODEL_REPOSITORY = temp_dir # type: ignore output = edge_manager.run_inference(detector_id, b"test_image", "image/jpeg", mode=ModeEnum.BINARY) @@ -245,9 +265,12 @@ def test_run_inference_stamps_mlb_key_without_oodd(self): detector_id = "test_detector" self._write_model_id(temp_dir, detector_id, 1, "prim_ksuid_only") - with mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit: + with ( + mock.patch("app.core.inference_image.INFERENCE_IMAGE_MODE", "minimal_if_compatible"), + mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit, + ): mock_submit.return_value = mock_response - edge_manager = EdgeInferenceManager(separate_oodd_inference=False) + edge_manager = EdgeInferenceManager(db_manager=_fake_db(minimal_compatible=True)) edge_manager.MODEL_REPOSITORY = temp_dir # type: ignore output = edge_manager.run_inference(detector_id, b"test_image", "image/jpeg", mode=ModeEnum.BINARY) @@ -266,9 +289,77 @@ def test_run_inference_missing_model_id_is_nonfatal(self): # No model_id.txt written; repository is empty. with mock.patch("app.core.edge_inference.submit_image_for_inference") as mock_submit: mock_submit.return_value = mock_response - edge_manager = EdgeInferenceManager() + edge_manager = EdgeInferenceManager(db_manager=_fake_db()) edge_manager.MODEL_REPOSITORY = temp_dir # type: ignore output = edge_manager.run_inference("test_detector", b"test_image", "image/jpeg", mode=ModeEnum.BINARY) assert "mlb_key" not in output assert "oodd_mlb_key" not in output + + def test_uses_separate_oodd_caches_db_read(self): + """Second call for the same detector_id is served from cache; DB is read only once.""" + with mock.patch("app.core.inference_image.INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _fake_db(minimal_compatible=False) # not minimal → uses_separate_oodd=True + edge_manager = EdgeInferenceManager(db_manager=db) + + result1 = edge_manager.uses_separate_oodd("cache_test_detector") + result2 = edge_manager.uses_separate_oodd("cache_test_detector") + + assert result1 is True + assert result2 is True + assert db.get_inference_deployment_record.call_count == 1 + + def test_uses_separate_oodd_default_on_missing_row(self): + """No DB row → conservative default: run separate OODD (full image expected).""" + with mock.patch("app.core.inference_image.INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = mock.Mock() + db.get_inference_deployment_record.return_value = None + edge_manager = EdgeInferenceManager(db_manager=db) + + result = edge_manager.uses_separate_oodd("missing_row_detector") + + assert result is True + + def test_uses_separate_oodd_cache_is_keyed_by_detector_id(self): + """Two detectors with different minimal_compatible values must get independent cache entries.""" + with mock.patch("app.core.inference_image.INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + rec_a = mock.Mock() + rec_a.minimal_compatible = True # detA is minimal → uses_separate_oodd=False + + rec_b = mock.Mock() + rec_b.minimal_compatible = False # detB is full → uses_separate_oodd=True + + db = mock.Mock() + db.get_inference_deployment_record.side_effect = lambda detector_id, is_oodd=False: ( + rec_a if detector_id == "detA" else rec_b + ) + + edge_manager = EdgeInferenceManager(db_manager=db) + + assert edge_manager.uses_separate_oodd("detA") is False + assert edge_manager.uses_separate_oodd("detB") is True + + def test_sync_models_skips_oodd_when_minimal_compatible( + self, edge_model_info_with_binary, oodd_model_info_with_binary + ): + """In minimal_if_compatible mode with minimal_compatible=True, OODD model dir is never written.""" + edge_model_info_with_binary.minimal_compatible = True + with ( + tempfile.TemporaryDirectory() as temp_dir, + mock.patch("app.core.edge_inference.fetch_model_info") as mock_fetch, + mock.patch("app.core.edge_inference.get_object_using_presigned_url") as mock_get_from_s3, + mock.patch("app.core.edge_inference.INFERENCE_IMAGE_MODE", "minimal_if_compatible"), + ): + mock_get_from_s3.return_value = b"test_model" + mock_fetch.return_value = (edge_model_info_with_binary, oodd_model_info_with_binary) + edge_manager = EdgeInferenceManager(db_manager=_fake_db(minimal_compatible=True)) + edge_manager.MODEL_REPOSITORY = temp_dir # type: ignore + detector_id = "test_detector" + + new_model, minimal_compatible = edge_manager.sync_models_from_cloud(detector_id) + + assert new_model is True + assert minimal_compatible is True + validate_model_directory(temp_dir, detector_id, 1, edge_model_info_with_binary) + oodd_dir = os.path.join(temp_dir, detector_id, "oodd") + assert not os.path.exists(oodd_dir), "OODD dir must not be created when running in minimal mode" diff --git a/test/model_updater/__init__.py b/test/model_updater/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/model_updater/test_update_models.py b/test/model_updater/test_update_models.py new file mode 100644 index 000000000..696522432 --- /dev/null +++ b/test/model_updater/test_update_models.py @@ -0,0 +1,287 @@ +"""Unit tests for the per-detector flavor logic in the model updater. + +These tests drive ``_check_new_models_and_inference_deployments`` for individual detectors +with their image flavor flipped on and off, including the hot-swap path and a crash-recovery +case. They mock every K8s/DB/IO surface so the loop runs in-process. +""" + +from unittest import mock + +import pytest + +from app.core import inference_image +from app.model_updater import update_models + + +def _detector_record(detector_id, minimal_compatible): + rec = mock.Mock() + rec.detector_id = detector_id + rec.minimal_compatible = minimal_compatible + return rec + + +def _make_db(records_by_did_and_oodd): + """records_by_did_and_oodd: {(detector_id, is_oodd): record_or_None}.""" + db = mock.Mock() + + def get_record(detector_id, is_oodd=False): + return records_by_did_and_oodd.get((detector_id, is_oodd)) + + db.get_inference_deployment_record.side_effect = get_record + + def update_record(model_name, fields_to_update): + # Find the matching record (model_name encodes detector_id + primary/oodd) + for (did, oodd), rec in records_by_did_and_oodd.items(): + if rec is None: + continue + from app.core.naming import get_edge_inference_model_name + + if get_edge_inference_model_name(did, is_oodd=oodd) == model_name: + for k, v in fields_to_update.items(): + setattr(rec, k, v) + return + + db.update_inference_deployment_record.side_effect = update_record + return db + + +def _make_deployment_manager(initial_images=None): + """A deployment_manager mock that tracks the current 'image' for each deployment name. + + initial_images: {deployment_name: image} for pre-existing deployments. + """ + state = dict(initial_images or {}) + mgr = mock.Mock() + + def get_inference_deployment(deployment_name): + if deployment_name in state: + d = mock.Mock() + d.spec.template.spec.containers = [mock.Mock(name="inference-server", image=state[deployment_name])] + return d + return None + + def get_deployment_image(deployment_name): + return state.get(deployment_name) + + def create(detector_id, is_oodd=False): + from app.core.naming import get_edge_inference_deployment_name + + name = get_edge_inference_deployment_name(detector_id, is_oodd=is_oodd) + # Whatever the per-detector image is at the moment of creation: + state[name] = mgr._desired_image_for_create(detector_id, is_oodd) + + def delete(detector_id, is_oodd=False): + from app.core.naming import get_edge_inference_deployment_name + + state.pop(get_edge_inference_deployment_name(detector_id, is_oodd=is_oodd), None) + + def fully_deleted(detector_id, is_oodd=False): + from app.core.naming import get_edge_inference_deployment_name + + return get_edge_inference_deployment_name(detector_id, is_oodd=is_oodd) not in state + + def rollout_complete(deployment_name): + return deployment_name in state + + mgr.get_inference_deployment.side_effect = get_inference_deployment + mgr.get_deployment_image.side_effect = get_deployment_image + mgr.create_inference_deployment.side_effect = create + mgr.delete_inference_deployment.side_effect = delete + mgr.is_inference_deployment_fully_deleted.side_effect = fully_deleted + mgr.is_inference_deployment_rollout_complete.side_effect = rollout_complete + mgr.update_inference_deployment.return_value = True + mgr._state = state # for test inspection + return mgr + + +FULL = "ecr/gl-edge-inference:tag" +MINIMAL = "ecr/gl-edge-inference-minimal:tag" + + +@pytest.fixture(autouse=True) +def _pin_images(): + with ( + mock.patch.object(inference_image, "FULL_INFERENCE_IMAGE_URI", FULL), + mock.patch.object(inference_image, "MINIMAL_INFERENCE_IMAGE_URI", MINIMAL), + ): + yield + + +@pytest.fixture +def edge_inference_manager_returning(): + """Build an edge_inference_manager mock whose sync_models_from_cloud returns a fixed value.""" + + def _build(new_model: bool, minimal_compatible: bool): + m = mock.Mock() + m.sync_models_from_cloud.return_value = (new_model, minimal_compatible) + m.MODEL_REPOSITORY = "/tmp/no-such-path" + return m + + return _build + + +class TestPerDetectorFlavor: + def test_minimal_compatible_detector_gets_minimal_image_no_oodd(self, edge_inference_manager_returning): + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db({("detA", False): _detector_record("detA", False)}) + dm = _make_deployment_manager() + dm._desired_image_for_create = lambda did, oodd: MINIMAL # detA flips to minimal_compatible=True + + eim = edge_inference_manager_returning(new_model=True, minimal_compatible=True) + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + oodd_name = get_edge_inference_deployment_name("detA", is_oodd=True) + assert dm._state.get(primary_name) == MINIMAL + assert oodd_name not in dm._state # no separate OODD pod for a minimal-image detector + + def test_incompatible_detector_gets_full_image_and_oodd(self, edge_inference_manager_returning): + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db( + {("detB", False): _detector_record("detB", False), ("detB", True): _detector_record("detB", None)} + ) + dm = _make_deployment_manager() + dm._desired_image_for_create = lambda did, oodd: FULL + + eim = edge_inference_manager_returning(new_model=True, minimal_compatible=False) + update_models._check_new_models_and_inference_deployments( + detector_id="detB", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + from app.core.naming import get_edge_inference_deployment_name + + assert dm._state.get(get_edge_inference_deployment_name("detB", is_oodd=False)) == FULL + assert dm._state.get(get_edge_inference_deployment_name("detB", is_oodd=True)) == FULL + + def test_cloud_true_in_standard_mode_persists_without_redeploy(self, edge_inference_manager_returning): + """standard mode stores minimal_compatible but must not tear down deployments on a DB-only flip.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "standard"): + db = _make_db( + {("detA", False): _detector_record("detA", False), ("detA", True): _detector_record("detA", None)} + ) + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + oodd_name = get_edge_inference_deployment_name("detA", is_oodd=True) + dm = _make_deployment_manager(initial_images={primary_name: FULL, oodd_name: FULL}) + eim = edge_inference_manager_returning(new_model=False, minimal_compatible=True) + + with mock.patch.object(update_models, "_redeploy_for_flavor_swap") as mock_swap: + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + mock_swap.assert_not_called() + + assert db.get_inference_deployment_record("detA", is_oodd=False).minimal_compatible is True + assert dm._state.get(primary_name) == FULL + assert dm._state.get(oodd_name) == FULL + + +class TestHotSwap: + def test_full_to_minimal_redeploys(self, edge_inference_manager_returning): + """A live full-image deployment is torn down and recreated on minimal when minimal_compatible flips True.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db( + {("detA", False): _detector_record("detA", False), ("detA", True): _detector_record("detA", None)} + ) + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + oodd_name = get_edge_inference_deployment_name("detA", is_oodd=True) + dm = _make_deployment_manager(initial_images={primary_name: FULL, oodd_name: FULL}) + # After the swap, new pods come up with MINIMAL because by that point minimal_compatible has been persisted + dm._desired_image_for_create = lambda did, oodd: MINIMAL + + eim = edge_inference_manager_returning(new_model=False, minimal_compatible=True) + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + assert dm._state.get(primary_name) == MINIMAL + assert oodd_name not in dm._state # the OODD pod was torn down by the swap + + def test_minimal_to_full_redeploys(self, edge_inference_manager_returning): + """A live minimal-image deployment is torn down and recreated on full when minimal_compatible flips False.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db({("detA", False): _detector_record("detA", True)}) # was minimal_compatible=True + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + oodd_name = get_edge_inference_deployment_name("detA", is_oodd=True) + dm = _make_deployment_manager(initial_images={primary_name: MINIMAL}) + # After the swap, new pods come up with FULL because minimal_compatible flipped False + dm._desired_image_for_create = lambda did, oodd: FULL + + eim = edge_inference_manager_returning(new_model=False, minimal_compatible=False) + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + assert dm._state.get(primary_name) == FULL + assert dm._state.get(oodd_name) == FULL # OODD pod created for full-image detector + + def test_stale_oodd_pod_removed_when_separate_oodd_mismatch(self, edge_inference_manager_returning): + """Primary already on minimal but a stale OODD pod remains — separate_oodd reconcile deletes it.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db({("detA", False): _detector_record("detA", True)}) + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + oodd_name = get_edge_inference_deployment_name("detA", is_oodd=True) + dm = _make_deployment_manager(initial_images={primary_name: MINIMAL, oodd_name: MINIMAL}) + dm._desired_image_for_create = lambda did, oodd: MINIMAL + + eim = edge_inference_manager_returning(new_model=False, minimal_compatible=True) + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + assert dm._state.get(primary_name) == MINIMAL + assert oodd_name not in dm._state + + def test_crash_mid_swap_recovers(self, edge_inference_manager_returning): + """If the updater 'crashes' after deletion (simulated by interrupting), the next cycle reaches steady state.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db({("detA", False): _detector_record("detA", False)}) + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + # Simulate post-crash state: previous cycle deleted the primary but never created the new one. + dm = _make_deployment_manager(initial_images={}) + dm._desired_image_for_create = lambda did, oodd: MINIMAL + + eim = edge_inference_manager_returning(new_model=False, minimal_compatible=True) + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + # The next cycle's create-if-missing path brings the primary back up on the right image. + assert dm._state.get(primary_name) == MINIMAL + + def test_failed_redeploy_does_not_set_swap_happened(self, edge_inference_manager_returning): + """When flavor swap fails, swap_happened stays False so a pending model rollout is not skipped.""" + with mock.patch.object(inference_image, "INFERENCE_IMAGE_MODE", "minimal_if_compatible"): + db = _make_db({("detA", False): _detector_record("detA", False)}) + from app.core.naming import get_edge_inference_deployment_name + + primary_name = get_edge_inference_deployment_name("detA", is_oodd=False) + oodd_name = get_edge_inference_deployment_name("detA", is_oodd=True) + dm = _make_deployment_manager(initial_images={primary_name: FULL, oodd_name: FULL}) + eim = edge_inference_manager_returning(new_model=True, minimal_compatible=True) + + # The mock's rollout_complete is always True when a deployment exists, which makes + # the "wait for rollout to start" poll spin forever. Provide a controlled sequence: + # False (rollout in-progress) → True → True so both poll loops exit immediately + # and the final rollout-complete check proceeds to dm.update_inference_deployment. + dm.is_inference_deployment_rollout_complete.side_effect = [False, True, True] + + with mock.patch.object(update_models, "_redeploy_for_flavor_swap", return_value=False): + update_models._check_new_models_and_inference_deployments( + detector_id="detA", edge_inference_manager=eim, deployment_manager=dm, db_manager=db + ) + + dm.update_inference_deployment.assert_called() diff --git a/test/validate_setup_helm.sh b/test/validate_setup_helm.sh index 185dc47dd..1ac9b2fa3 100755 --- a/test/validate_setup_helm.sh +++ b/test/validate_setup_helm.sh @@ -35,7 +35,7 @@ helm install -n default ${HELM_RELEASE_NAME} deploy/helm/groundlight-edge-endpoi --set edgeEndpointPort=$EDGE_ENDPOINT_PORT \ --set=edgeEndpointTag=$TAG \ --set namespace=$DEPLOYMENT_NAMESPACE \ - --set useMinimalImage=$USE_MINIMAL_IMAGE + --set inferenceImageMode=${INFERENCE_IMAGE_MODE:-standard} echo "Waiting for edge-endpoint pods to rollout in namespace $DEPLOYMENT_NAMESPACE..."