diff --git a/client/qiskit_serverless/core/clients/serverless_client.py b/client/qiskit_serverless/core/clients/serverless_client.py index 5c5cf03ce..c981f77e5 100644 --- a/client/qiskit_serverless/core/clients/serverless_client.py +++ b/client/qiskit_serverless/core/clients/serverless_client.py @@ -236,7 +236,6 @@ def jobs(self, function: Optional[QiskitFunction] = None, **kwargs) -> List[Job] job.get("id"), job_service=self, raw_data=job, - compute_profile=job.get("compute_profile"), ) for job in response_data.get("results", []) ] @@ -291,7 +290,6 @@ def provider_jobs(self, function: Optional[QiskitFunction], **kwargs) -> List[Jo job.get("id"), job_service=self, raw_data=job, - compute_profile=job.get("compute_profile"), ) for job in response_data.get("results", []) ] @@ -314,7 +312,6 @@ def job(self, job_id: str) -> Optional[Job]: job = Job( job_id=job_id, job_service=self, - compute_profile=response_data.get("compute_profile"), ) return job @@ -376,7 +373,6 @@ def run( return Job( job_id, job_service=self, - compute_profile=response_data.get("compute_profile"), ) def get_job_data(self, job_id: str) -> Optional[dict]: diff --git a/client/qiskit_serverless/core/job.py b/client/qiskit_serverless/core/job.py index 2b226808d..b6e1c725c 100644 --- a/client/qiskit_serverless/core/job.py +++ b/client/qiskit_serverless/core/job.py @@ -168,7 +168,6 @@ def __init__( job_id: str, job_service: JobService, raw_data: Optional[Dict[str, Any]] = None, - compute_profile: Optional[str] = None, ): """Job class for async script execution. @@ -178,7 +177,6 @@ def __init__( """ self.job_id = job_id self._job_service = job_service - self.compute_profile = compute_profile self.raw_data = raw_data or {} @property diff --git a/client/tests/core/test_serverless_client_jobs.py b/client/tests/core/test_serverless_client_jobs.py index 734111969..2068e1e0c 100644 --- a/client/tests/core/test_serverless_client_jobs.py +++ b/client/tests/core/test_serverless_client_jobs.py @@ -1140,16 +1140,13 @@ def test_run_with_compute_profile(self, mock_client): json=mock_response, ) - job = mock_client.run(program="test-program", compute_profile="gx3d-24x120x1a100p") + mock_client.run(program="test-program", compute_profile="gx3d-24x120x1a100p") - # Verify client sent compute_profile to API + # Verify client sent the (deprecated) compute_profile input to the API request_data = json.loads(mock_request.last_request.text) assert "compute_profile" in request_data assert request_data["compute_profile"] == "gx3d-24x120x1a100p" - # Verify Job.compute_profile property works - assert job.compute_profile == "gx3d-24x120x1a100p" - def test_run_without_compute_profile(self, mock_client): """Test run() without compute_profile - backend applies default.""" # Mock response includes default compute_profile applied by backend @@ -1169,11 +1166,8 @@ def test_run_without_compute_profile(self, mock_client): json=mock_response, ) - job = mock_client.run(program="test-program") + mock_client.run(program="test-program") # Verify client sent compute_profile as None (backend will apply default) request_data = json.loads(mock_request.last_request.text) assert request_data["compute_profile"] is None - - # Verify backend applied the default - assert job.compute_profile == "cx3d-4x16" diff --git a/gateway/api/admin.py b/gateway/api/admin.py index 7e763cb8d..4b86c51f6 100644 --- a/gateway/api/admin.py +++ b/gateway/api/admin.py @@ -505,7 +505,6 @@ class JobAdmin(admin.ModelAdmin): "fields": [ "filler", "fleet_id", - "compute_profile", "compute_profile_fk", "size_source", "function_size", @@ -575,7 +574,11 @@ def job_timeline_view(self, request): continue # one single query: the rendering iterates the jobs again, and re-running the queryset # could come back empty (deleted in between) and break the rendering half way through - jobs = list(Job.objects.filter(id__in=id_list).select_related("author").prefetch_related("job_events")) + jobs = list( + Job.objects.filter(id__in=id_list) + .select_related("author", "compute_profile_fk") + .prefetch_related("job_events") + ) if not id_list or not jobs: messages.error(request, "No jobs selected for the timeline.") return redirect(reverse("admin:api_job_changelist")) @@ -725,7 +728,7 @@ def index(self, request, extra_context=None): if timeline_context is None: recent_jobs = list( Job.objects.filter(runner=Program.FLEETS) - .select_related("author") + .select_related("author", "compute_profile_fk") .prefetch_related("job_events") .order_by("-created")[:20] ) diff --git a/gateway/api/domain/job_timeline.py b/gateway/api/domain/job_timeline.py index 9214fe96f..763ab5e7b 100644 --- a/gateway/api/domain/job_timeline.py +++ b/gateway/api/domain/job_timeline.py @@ -67,7 +67,7 @@ def _jobs_from_queryset(jobs_qs): "status": job.status, "runner": job.runner, "filler": job.filler, - "profile": job.compute_profile or "-", + "profile": job.compute_profile_id or "-", "created": job.created, "updated": job.updated, "running_started_at": job.running_started_at, diff --git a/gateway/api/migrations/0064_remove_job_compute_profile_state.py b/gateway/api/migrations/0064_remove_job_compute_profile_state.py new file mode 100644 index 000000000..9250cf09f --- /dev/null +++ b/gateway/api/migrations/0064_remove_job_compute_profile_state.py @@ -0,0 +1,37 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0063_merge_20260902_1720"), + ] + + operations = [ + # Step 1 of a two-step removal of Job.compute_profile (the transitional + # string). compute_profile_fk (FK -> ComputeProfile) is the source of + # truth; readers in this repo now go through Job.compute_profile_id, which + # reads the FK. Here the field is dropped from Django's state ONLY; the + # api_job.compute_profile column is left in place so the previous release + # -- whose Job model still declares the field and lists it in every SELECT + # -- stays deployable. The real DROP COLUMN happens in a later release, + # once no release that declares the field can still be deployed (mirrors + # the Program.default_compute_profile removal, migrations 0057 -> 0058). + # + # BLOCKED: this migration must NOT ship until the billing team repoints + # KafkaEventStreamsClient._build_classical_metric_type + # (gateway/core/ibm_cloud/event_streams/kafka_event_streams_client.py) off + # job.compute_profile and onto job.compute_profile_fk.compute_profile_id. + # That file is owned by another team and is deliberately untouched here; + # removing the field from the model state while it still reads the string + # attribute would raise AttributeError when a usage event is built. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name="job", + name="compute_profile", + ), + ], + database_operations=[], + ), + ] diff --git a/gateway/api/use_cases/programs/run.py b/gateway/api/use_cases/programs/run.py index 9436241c2..5b06fe0a2 100644 --- a/gateway/api/use_cases/programs/run.py +++ b/gateway/api/use_cases/programs/run.py @@ -72,12 +72,11 @@ def _get_runner_config( ) -> RunnerConfig: """Resolve the compute profile and sizing provenance for a run. - ``compute_profile`` (string) is transitional and will be removed; the FK - ``compute_profile_fk`` is the source of truth going forward. They always agree - at creation. A Fleets job always resolves to a profile string; if no - ``ComputeProfile`` row is registered for it, that is a deployment - misconfiguration and we reject the job rather than store a null FK. Ray leaves - ``compute_profile`` None (profiles are a Fleets concept), so the FK stays null. + ``compute_profile_fk`` is the source of truth for the profile a job runs at + and is what gets stored on the job. A Fleets job always resolves to a profile; + if no ``ComputeProfile`` row is registered for it, that is a deployment + misconfiguration and we reject the job rather than store a null FK. Ray is not + profiled (profiles are a Fleets concept), so its FK stays null. Because the size determines the compute profile (and not the reverse -- two sizes can map to one profile), the returned :class:`RunnerConfig` also records @@ -237,7 +236,6 @@ def execute( # pylint: disable=too-many-locals, too-many-branches author=user, gpu=runner_config.gpu, runner=function.runner, - compute_profile=runner_config.compute_profile, compute_profile_fk=runner_config.compute_profile_fk, size_source=runner_config.size_source, function_size=runner_config.function_size, diff --git a/gateway/api/v1/views/jobs/list.py b/gateway/api/v1/views/jobs/list.py index 4f10f3cd1..fdc4f8c33 100644 --- a/gateway/api/v1/views/jobs/list.py +++ b/gateway/api/v1/views/jobs/list.py @@ -86,7 +86,7 @@ class JobSerializerWithoutResult(serializers.ModelSerializer): class Meta: model = Job - fields = ["id", "status", "program", "created", "sub_status", "compute_profile", "compute_profile_fk"] + fields = ["id", "status", "program", "created", "sub_status", "compute_profile_fk"] ref_name = "JobsListWithoutResultInputSerializer" diff --git a/gateway/api/v1/views/jobs/retrieve.py b/gateway/api/v1/views/jobs/retrieve.py index 7eb4da3ca..74d64cf35 100644 --- a/gateway/api/v1/views/jobs/retrieve.py +++ b/gateway/api/v1/views/jobs/retrieve.py @@ -82,7 +82,6 @@ class Meta: "created", "sub_status", "fleet_id", - "compute_profile", "compute_profile_fk", "business_model", ] @@ -113,7 +112,6 @@ class Meta: "created", "sub_status", "fleet_id", - "compute_profile", "compute_profile_fk", "business_model", ] diff --git a/gateway/api/v1/views/programs/get_jobs.py b/gateway/api/v1/views/programs/get_jobs.py index f437c3805..3b672a22e 100644 --- a/gateway/api/v1/views/programs/get_jobs.py +++ b/gateway/api/v1/views/programs/get_jobs.py @@ -50,7 +50,7 @@ class OutputSerializer(serializers.ModelSerializer): class Meta: model = Job - fields = ["id", "result", "status", "program", "created", "sub_status", "fleet_id", "compute_profile"] + fields = ["id", "result", "status", "program", "created", "sub_status", "fleet_id"] ref_name = "ProgramsGetJobsOutput" @@ -65,7 +65,7 @@ class OutputSerializerWithoutResult(serializers.ModelSerializer): class Meta: model = Job - fields = ["id", "status", "program", "created", "sub_status", "fleet_id", "compute_profile"] + fields = ["id", "status", "program", "created", "sub_status", "fleet_id"] ref_name = "ProgramsGetJobsOutputWithoutResult" diff --git a/gateway/api/v1/views/programs/run.py b/gateway/api/v1/views/programs/run.py index 860f851eb..720c2f60f 100644 --- a/gateway/api/v1/views/programs/run.py +++ b/gateway/api/v1/views/programs/run.py @@ -110,11 +110,9 @@ class Meta: class OutputSerializer(serializers.ModelSerializer): """Response serializer for a queued job.""" - compute_profile = serializers.CharField(required=False, allow_null=True, allow_blank=True, default=None) - class Meta: model = Job - fields = ["id", "result", "status", "program", "created", "arguments", "compute_profile", "size_source"] + fields = ["id", "result", "status", "program", "created", "arguments", "size_source"] ref_name = "ProgramsRunOutput" diff --git a/gateway/core/models.py b/gateway/core/models.py index 673d4c0a0..fe792183a 100644 --- a/gateway/core/models.py +++ b/gateway/core/models.py @@ -550,12 +550,6 @@ class Job(models.Model): help_text="True when this job was created by the filler-jobs balancer to occupy idle GPU " "capacity, instead of coming from a real user request.", ) - compute_profile = models.CharField( - max_length=255, - null=True, - blank=True, - help_text="Code Engine compute profile for Fleets runner (e.g., gx3d-24x120x1a100p)", - ) logs = models.TextField(default="No logs yet.") runner = models.CharField( max_length=20, choices=Program.RUNNER_CHOICES, default=Program.RAY, help_text="Execution backend: ray or fleets" @@ -658,6 +652,16 @@ def in_terminal_state(self): """Returns true if job is in terminal state.""" return self.status in self.TERMINAL_STATUSES + @property + def compute_profile_id(self) -> str | None: + """Bare compute-profile string from the FK (the source of truth). + + Returns None for Ray jobs and any historical row with no + ``compute_profile_fk``. Reads ``compute_profile_fk_id`` first so an + unset FK costs no extra query. + """ + return self.compute_profile_fk.compute_profile_id if self.compute_profile_fk_id else None + def save_direct(self, fields: list[str]) -> None: """Persist selected fields bypassing optimistic-locking validation. diff --git a/gateway/core/services/runners/fleets_runner.py b/gateway/core/services/runners/fleets_runner.py index 9dd4fa2b3..be9b88a4d 100644 --- a/gateway/core/services/runners/fleets_runner.py +++ b/gateway/core/services/runners/fleets_runner.py @@ -199,7 +199,7 @@ def submit(self) -> None: logger.info( "job_id=[%s] profile [%s] → cpu=%s memory=%s gpu=%s", self.job.id, - self.job.compute_profile or "default", + self.job.compute_profile_id or "default", cpu_limit, memory_limit, scale_gpu, @@ -792,10 +792,10 @@ def _build_fleet_name(self) -> str: """ prefix = "fil" if self.job.filler else "job" function = _fleet_name_segment(self.job.program.title) - # Unlike the title and the username, compute_profile is nullable, and submit() runs on + # Unlike the title and the username, the compute profile is nullable, and submit() runs on # settings.DEFAULT_COMPUTE_PROFILE when it is unset (see _parse_compute_profile). Name the - # fleet after the profile it actually runs on rather than after the empty column. - profile = _fleet_name_segment(self.job.compute_profile or settings.DEFAULT_COMPUTE_PROFILE) + # fleet after the profile it actually runs on rather than after the empty value. + profile = _fleet_name_segment(self.job.compute_profile_id or settings.DEFAULT_COMPUTE_PROFILE) username = _fleet_name_segment(self.job.author.username) return f"{prefix}-{function}-{profile}-{username}-{_fleet_name_timestamp()}" @@ -810,7 +810,7 @@ def _parse_compute_profile(self) -> tuple[str, str, dict | None]: is the V2GPUScalePrototype dict or ``None`` when no GPU is specified in the profile. """ - profile = self.job.compute_profile or settings.DEFAULT_COMPUTE_PROFILE + profile = self.job.compute_profile_id or settings.DEFAULT_COMPUTE_PROFILE # Normalize away any instance-family prefix. New jobs are already bare # (the view normalizes at ingest), but this stays a safety net for diff --git a/gateway/scheduler/tasks/balance_filler_jobs.py b/gateway/scheduler/tasks/balance_filler_jobs.py index 7b2deca15..4e8bf3034 100644 --- a/gateway/scheduler/tasks/balance_filler_jobs.py +++ b/gateway/scheduler/tasks/balance_filler_jobs.py @@ -100,7 +100,7 @@ def _balance_filler_jobs(self, program: Program, filler_jobs: list[Job]) -> None self._stop_filler_jobs(stale) if len(current) < target: - self._create_filler_job(program, compute_profile) + self._create_filler_job(program) elif len(current) > target: self._stop_filler_jobs(current[: len(current) - target]) @@ -209,7 +209,7 @@ def _discard_unsubmitted_filler_jobs(self) -> None: ) self._mark_failed(job) - def _create_filler_job(self, program: Program, compute_profile: str) -> None: + def _create_filler_job(self, program: Program) -> None: """Create and submit one filler job, the most this task creates per loop. One per loop rather than the whole shortfall, so the COS upload and the Code @@ -221,10 +221,10 @@ def _create_filler_job(self, program: Program, compute_profile: str) -> None: # A shutdown is not a failure of the work, so it buys no delay. if self.kill_signal.received: return - if not self._submit_filler_job(program, compute_profile): + if not self._submit_filler_job(program): self._retry_loops = RETRY_AFTER_LOOPS - def _submit_filler_job(self, program: Program, compute_profile: str) -> bool: + def _submit_filler_job(self, program: Program) -> bool: """Create and submit one filler job. True when it reached PENDING.""" project = program.code_engine_project job = Job( @@ -234,7 +234,6 @@ def _submit_filler_job(self, program: Program, compute_profile: str) -> bool: author=program.author, filler=True, runner=Program.FLEETS, - compute_profile=compute_profile, compute_profile_fk=program.default_size.compute_profile, size_source=Job.SIZE_SOURCE_NONE, function_size=program.default_size, diff --git a/gateway/tests/api/test_compute_profile.py b/gateway/tests/api/test_compute_profile.py index 5e9db150b..99dc3c7b8 100644 --- a/gateway/tests/api/test_compute_profile.py +++ b/gateway/tests/api/test_compute_profile.py @@ -86,11 +86,10 @@ def test_create_job_with_compute_profile(api_client, program): response = api_client.post(url, data, format="json") assert response.status_code == status.HTTP_200_OK - # The prefix is normalized away: the canonical bare form is what we store. - assert response.data["compute_profile"] == "24x120x1a100p" + # The prefix is normalized away: the canonical bare form is what we store (on the FK). job = Job.objects.get(id=response.data["id"]) - assert job.compute_profile == "24x120x1a100p" + assert job.compute_profile_id == "24x120x1a100p" @override_settings(DEFAULT_COMPUTE_PROFILE="16x128") @@ -107,10 +106,9 @@ def test_create_job_with_bare_compute_profile(api_client, program): response = api_client.post(url, data, format="json") assert response.status_code == status.HTTP_200_OK - assert response.data["compute_profile"] == "24x120x1a100p" job = Job.objects.get(id=response.data["id"]) - assert job.compute_profile == "24x120x1a100p" + assert job.compute_profile_id == "24x120x1a100p" @override_settings(DEFAULT_COMPUTE_PROFILE="16x128") @@ -126,11 +124,10 @@ def test_create_job_without_compute_profile_uses_default(api_client, program): response = api_client.post(url, data, format="json") assert response.status_code == status.HTTP_200_OK - assert response.data["compute_profile"] == "16x128" # Verify job was created with default compute_profile job = Job.objects.get(id=response.data["id"]) - assert job.compute_profile == "16x128" + assert job.compute_profile_id == "16x128" @pytest.mark.parametrize( @@ -159,7 +156,8 @@ def test_compute_profile_validation_valid_formats(api_client, program, submitted response = api_client.post(url, data, format="json") assert response.status_code == status.HTTP_200_OK - assert response.data["compute_profile"] == stored + job = Job.objects.get(id=response.data["id"]) + assert job.compute_profile_id == stored @pytest.mark.parametrize( @@ -222,11 +220,10 @@ def test_run_with_function_size_happy_path(api_client, program): response = api_client.post(url, data, format="json") assert response.status_code == status.HTTP_200_OK - assert response.data["compute_profile"] == "4x16" assert response.data["size_source"] == Job.SIZE_SOURCE_REQUESTED job = Job.objects.get(id=response.data["id"]) - assert job.compute_profile == "4x16" + assert job.compute_profile_id == "4x16" assert job.size_source == Job.SIZE_SOURCE_REQUESTED assert job.function_size.function_size == "m" @@ -246,7 +243,8 @@ def test_run_with_function_size_is_normalized(api_client, program): response = api_client.post(url, data, format="json") assert response.status_code == status.HTTP_200_OK - assert response.data["compute_profile"] == "4x16" + job = Job.objects.get(id=response.data["id"]) + assert job.compute_profile_id == "4x16" def test_run_with_both_compute_profile_and_function_size_returns_400(api_client, program): @@ -330,12 +328,12 @@ def test_list_includes_sizes_and_default(api_client, user, program): def test_job_list_includes_compute_profile(api_client, user, program): - """Test that job list endpoint includes compute_profile.""" - # Create a job with compute_profile + """Test that job list endpoint includes the compute profile (via compute_profile_fk).""" + profile = ComputeProfile.objects.get(compute_profile_id="24x120x1a100p") job = TestUtils.create_job( author=user, program=program, - compute_profile="gx3d-24x120x1a100p", + compute_profile_fk=profile, ) url = reverse("v1:jobs-list") @@ -348,20 +346,20 @@ def test_job_list_includes_compute_profile(api_client, user, program): # Response data is paginated with results field containing list of job dicts job_data = next((j for j in response.data["results"] if j.get("id") == str(job.id)), None) assert job_data is not None - assert job_data.get("compute_profile") == "gx3d-24x120x1a100p" + assert job_data["compute_profile_fk"]["compute_profile_id"] == "24x120x1a100p" def test_job_detail_includes_compute_profile(api_client, user, program): - """Test that job detail endpoint includes compute_profile.""" - # Create a job with compute_profile + """Test that job detail endpoint includes the compute profile (via compute_profile_fk).""" + profile = ComputeProfile.objects.get(compute_profile_id="24x120x1a100p") job = TestUtils.create_job( author=user, program=program, - compute_profile="gx3d-24x120x1a100p", + compute_profile_fk=profile, ) url = reverse("v1:retrieve", kwargs={"job_id": job.id}) response = api_client.get(url, format="json") assert response.status_code == status.HTTP_200_OK - assert response.data["compute_profile"] == "gx3d-24x120x1a100p" + assert response.data["compute_profile_fk"]["compute_profile_id"] == "24x120x1a100p" diff --git a/gateway/tests/api/use_cases/programs/test_run.py b/gateway/tests/api/use_cases/programs/test_run.py index 149e7822d..c7ed3b18b 100644 --- a/gateway/tests/api/use_cases/programs/test_run.py +++ b/gateway/tests/api/use_cases/programs/test_run.py @@ -146,7 +146,7 @@ def test_fleets_job_sets_compute_profile_fk_from_default(self, user, ce_project, job = RunFunctionUseCase().execute(user, accessible, make_input()) - assert job.compute_profile == "16x128" + assert job.compute_profile_id == "16x128" assert job.compute_profile_fk == profile # Nothing requested and no default_size: sized by the deployment default, # so no FunctionSize row backs it. @@ -167,7 +167,7 @@ def test_fleets_job_sets_compute_profile_fk_from_explicit_request(self, user, ce job = RunFunctionUseCase().execute(user, accessible, make_input(compute_profile="24x120x1a100p")) - assert job.compute_profile == "24x120x1a100p" + assert job.compute_profile_id == "24x120x1a100p" assert job.compute_profile_fk == profile # Sized by the deprecated compute_profile input; no size row applies. assert job.size_source == Job.SIZE_SOURCE_COMPUTE_PROFILE @@ -207,7 +207,7 @@ def test_ray_job_leaves_compute_profile_fk_null(self, user): job = RunFunctionUseCase().execute(user, accessible, make_input()) - assert job.compute_profile is None + assert job.compute_profile_id is None assert job.compute_profile_fk is None assert job.size_source == Job.SIZE_SOURCE_NONE assert job.function_size is None @@ -222,7 +222,7 @@ def test_fleets_job_resolves_compute_profile_from_function_size(self, user, ce_p job = RunFunctionUseCase().execute(user, accessible, make_input(function_size="m")) - assert job.compute_profile == "16x128" + assert job.compute_profile_id == "16x128" assert job.compute_profile_fk == profile # A user-requested size records REQUESTED and the exact size row (so a # different size mapping to the same profile stays distinguishable). @@ -265,7 +265,7 @@ def test_fleets_job_uses_default_size_when_nothing_requested(self, user, ce_proj job = RunFunctionUseCase().execute(user, accessible, make_input()) - assert job.compute_profile == "16x128" + assert job.compute_profile_id == "16x128" assert job.compute_profile_fk == default_profile # Platform filled in the default: distinguishable from a user picking the # same size, which would record REQUESTED. @@ -279,7 +279,7 @@ def test_ray_job_ignores_function_size(self, user): job = RunFunctionUseCase().execute(user, accessible, make_input(function_size="m")) - assert job.compute_profile is None + assert job.compute_profile_id is None assert job.compute_profile_fk is None assert job.size_source == Job.SIZE_SOURCE_NONE assert job.function_size is None diff --git a/gateway/tests/core/services/runners/test_fleets_runner.py b/gateway/tests/core/services/runners/test_fleets_runner.py index d25d65c6d..21c89950d 100644 --- a/gateway/tests/core/services/runners/test_fleets_runner.py +++ b/gateway/tests/core/services/runners/test_fleets_runner.py @@ -109,7 +109,7 @@ def _make_submit_runner() -> tuple[FleetsRunner, MagicMock]: mock_job.PENDING = "PENDING" mock_job.RUNNING = "RUNNING" mock_job.config = None - mock_job.compute_profile = None + mock_job.compute_profile_id = None mock_job.program.title = "my-function" mock_job.author.username = "IBMid-1000000000" mock_job.program.image = None @@ -373,7 +373,7 @@ def test_submit_fleet_name_describes_the_job(): """A real job's fleet is named job---.""" runner, mock_handler = _make_submit_runner() runner.job.program.title = "my-function" - runner.job.compute_profile = "160x1792x8h100" + runner.job.compute_profile_id = "160x1792x8h100" runner.job.author.username = "alice" with _patch_settings(): @@ -390,7 +390,7 @@ def test_submit_fleet_name_uses_filler_prefix_for_filler_jobs(): runner, mock_handler = _make_submit_runner() runner.job.filler = True runner.job.program.title = "my-function" - runner.job.compute_profile = "160x1792x8h100" + runner.job.compute_profile_id = "160x1792x8h100" runner.job.author.username = "alice" with _patch_settings(): @@ -404,7 +404,7 @@ def test_submit_fleet_name_is_sanitized_and_bounded(): runner, mock_handler = _make_submit_runner() runner.job.filler = True runner.job.program.title = "My Function! With Spaces And A Very Long Title" - runner.job.compute_profile = "gx3d-24x120x1a100p" + runner.job.compute_profile_id = "gx3d-24x120x1a100p" runner.job.author.username = "IBMid-1000000000" with _patch_settings(): @@ -452,7 +452,7 @@ def test_submit_raises_runner_error_when_no_fleet_id_returned(): def test_submit_parses_compute_profile_with_gpu(): """submit() parses compute_profile into cpu, memory, and gpu.""" runner, mock_handler = _make_submit_runner() - runner.job.compute_profile = "gx3d-24x120x1a100p" + runner.job.compute_profile_id = "gx3d-24x120x1a100p" with _patch_settings(): runner.submit() @@ -466,7 +466,7 @@ def test_submit_parses_compute_profile_with_gpu(): def test_submit_parses_compute_profile_without_prefix(): """submit() parses profiles without a prefix like '24x120x2a100p'.""" runner, mock_handler = _make_submit_runner() - runner.job.compute_profile = "24x120x2a100p" + runner.job.compute_profile_id = "24x120x2a100p" with _patch_settings(): runner.submit() @@ -480,7 +480,7 @@ def test_submit_parses_compute_profile_without_prefix(): def test_submit_parses_compute_profile_without_gpu(): """submit() parses a CPU-only profile correctly.""" runner, mock_handler = _make_submit_runner() - runner.job.compute_profile = "cx3d-4x16" + runner.job.compute_profile_id = "cx3d-4x16" with _patch_settings(): runner.submit() @@ -524,7 +524,7 @@ def test_submit_default_profile_in_settings_is_parseable(): def test_submit_raises_on_unparseable_compute_profile(): """submit() raises RunnerError when compute_profile cannot be parsed.""" runner, _ = _make_submit_runner() - runner.job.compute_profile = "not-a-valid-profile" + runner.job.compute_profile_id = "not-a-valid-profile" with _patch_settings(): with pytest.raises(RunnerError, match="Could not parse compute_profile"): diff --git a/gateway/tests/scheduler/test_balance_filler_jobs.py b/gateway/tests/scheduler/test_balance_filler_jobs.py index 5a0cd3821..7218d4cb1 100644 --- a/gateway/tests/scheduler/test_balance_filler_jobs.py +++ b/gateway/tests/scheduler/test_balance_filler_jobs.py @@ -92,7 +92,7 @@ def test_creates_filler_jobs_up_to_the_configured_slots(filler_program): assert fillers.count() == 4 assert submit.call_count == 4 assert arguments.call_count == 4 - assert {job.compute_profile for job in fillers} == {_PROFILE} + assert {job.compute_profile_id for job in fillers} == {_PROFILE} assert {job.runner for job in fillers} == {Program.FLEETS} assert {job.author for job in fillers} == {filler_program.author} assert {job.size_source for job in fillers} == {Job.SIZE_SOURCE_NONE} @@ -118,7 +118,6 @@ def test_real_running_jobs_reduce_the_number_of_filler_jobs(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, ) task = _make_task() @@ -144,7 +143,6 @@ def test_a_prefixed_profile_row_still_counts_real_jobs(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=prefixed.compute_profile_id, compute_profile_fk=prefixed, ) task = _make_task() @@ -162,7 +160,6 @@ def test_real_jobs_on_another_profile_do_not_count(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=other.compute_profile_id, compute_profile_fk=other, ) task = _make_task() @@ -180,7 +177,6 @@ def test_stops_the_oldest_filler_jobs_when_there_are_too_many(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, fleet_id=f"fleet-{index}", @@ -210,7 +206,6 @@ def test_does_nothing_when_the_count_already_matches(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, fleet_id=f"fleet-{index}", @@ -231,7 +226,6 @@ def test_zero_slots_stops_every_filler_job(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, fleet_id="fleet-0", @@ -263,7 +257,6 @@ def test_deactivated_stops_every_filler_job(filler_program, config_key, value): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, fleet_id="fleet-0", @@ -333,7 +326,6 @@ def test_a_filler_job_that_was_never_submitted_is_discarded(filler_program): program=filler_program, status=Job.QUEUED, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, ) @@ -360,7 +352,6 @@ def test_filler_jobs_on_another_profile_are_always_stopped(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=old_row, filler=True, fleet_id="fleet-stale", @@ -391,7 +382,6 @@ def test_filler_jobs_of_another_program_are_always_stopped(filler_program): program=old_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, fleet_id="fleet-old-program", @@ -425,7 +415,6 @@ def test_the_occupancy_of_the_protected_profile_is_reported(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, ) task = _make_task() @@ -455,7 +444,6 @@ def test_a_fleet_that_cannot_be_cancelled_keeps_the_job_active(filler_program): program=filler_program, status=Job.RUNNING, runner=Program.FLEETS, - compute_profile=_PROFILE, compute_profile_fk=filler_program.default_size.compute_profile, filler=True, fleet_id="fleet-stuck", diff --git a/gateway/tests/test_job_timeline_admin.py b/gateway/tests/test_job_timeline_admin.py index 36093a56c..25ade8884 100644 --- a/gateway/tests/test_job_timeline_admin.py +++ b/gateway/tests/test_job_timeline_admin.py @@ -12,7 +12,7 @@ from api.domain.job_timeline import FILLER_TEXT_COLOR, render_job_timeline from core.domain.business_models import BusinessModel from core.model_managers.job_events import JobEventContext, JobEventOrigin -from core.models import Job, JobEvent, Program, Provider +from core.models import ComputeProfile, Job, JobEvent, Program, Provider def _job_with_events( @@ -37,11 +37,16 @@ def _job_with_events( user = User.objects.create_user(username=f"u{uuid4().hex[:8]}", password="x") provider = Provider.objects.create(name=f"P{uuid4().hex[:8]}") program = Program.objects.create(title="t", author=user, provider=provider) + # The timeline reads the profile through job.compute_profile_id (the FK), so the + # value under test has to live on a ComputeProfile row, not a bare string column. + compute_profile_fk = None + if compute_profile is not None: + compute_profile_fk, _ = ComputeProfile.objects.get_or_create(compute_profile_id=compute_profile) job = Job.objects.create( author=user, program=program, status=status, - compute_profile=compute_profile, + compute_profile_fk=compute_profile_fk, runner=runner, **extra_fields, ) diff --git a/releasenotes/notes/remove-job-compute-profile-string-cf1fc90c145bada1.yaml b/releasenotes/notes/remove-job-compute-profile-string-cf1fc90c145bada1.yaml new file mode 100644 index 000000000..bccdc5f0f --- /dev/null +++ b/releasenotes/notes/remove-job-compute-profile-string-cf1fc90c145bada1.yaml @@ -0,0 +1,15 @@ +--- +upgrade: + - | + The transitional ``compute_profile`` string field has been removed from job + API responses and from the client ``Job`` object. It was superseded by + ``compute_profile_fk`` (the registered compute profile, exposed as a nested + object on the job list and retrieve responses), which is the source of truth + for the profile a job ran on. Read the compute profile from + ``compute_profile_fk`` instead. + + The deprecated ``compute_profile`` input to ``function.run(...)`` is + unaffected by this change; prefer ``function_size``. + + The underlying database column is retained for one release and dropped in a + later one, so this change is safe to roll back.