Enforce MLflow request limits on batch logging and tags
What does this MR do and why?
The MLflow-compatible API now rejects runs/log-batch requests over MLflow's own LogBatch limits (1,000 metrics, 100 params, 100 tags, 1,000 items in total), runs/create and experiments/create requests with more than 1,000 tags, and registered-models/create and model-versions/create requests with more than 100 tags. Today these endpoints accept any number of items, and each item is a row written in the request, so large requests hold a Puma worker and a database transaction for a long time. The limits are behind the mlflow_enforce_request_limits feature flag (gitlab_com_derisk, default off, project actor); rollback is to disable the flag.
Design notes
- The checks live in
lib/api/ml/mlflow/api_helpers.rb(check_log_batch_limits!,check_tags_limit!) and run first in each endpoint body, after authentication and before any record lookup or write. - Rejected requests get
400witherror_code: INVALID_PARAMETER_VALUE, the same shape as the other MLflow validation errors in this API (invalid_parameter!). Batch limits use the MLflow tracking server's message (A batch logging request can contain at most 1000 metrics. Got 1001 metrics. Please split up metrics across multiple requests and try again.); the create-request cap usesA request can contain at most 100 tags. Got 101 tags. - The batch limits are MLflow's (
MAX_*_PER_BATCH). The create-request caps are separate GitLab constants (MAX_TAGS_PER_BULK_CREATE_REQUEST,MAX_TAGS_PER_CREATE_REQUEST), because MLflow does not limit tags on create requests. log-batchreadtagsraw fromparams[:tags]; it was not declared. It is now declared as an array ofkey/valuestrings, likemetricsandparams, so Grape validates each tag. This part is not behind the flag (Grape validation cannot be gated per project). Behavior change outside the flag: a tag withoutkeyreturned400 INVALID_PARAMETER_VALUEfrom the databaseNOT NULLerror, and now returns Grape's400 {"error": "tags[0][key] is missing"}; a non-arraytagsreturned500, and now returns400. The OpenAPI document gains thetagsparameter.- Create-request caps: MLflow documents no count limit for
CreateRun,CreateExperiment,CreateRegisteredModelorCreateModelVersiontags.runs/createandexperiments/createinsert all tags with oneinsert_all, so they allow 1,000 tags, MLflow's LogBatch total.registered-models/createandmodel-versions/createinsert one row per tag, so they keep 100, MLflow's LogBatch tag limit. The MLflow client adds only a few system tags tocreate_run(mlflow.user,mlflow.source.*,mlflow.runName). runs/createwrites toml_candidate_metadata, the table that thelog-batchtag limit covers, andexperiments/createwrites toml_experiment_metadata; both now have the cap.- The per-tag
create!loop inMl::CreateModelService#add_metadataandMl::ModelVersion#add_metadatais unchanged; the cap alone bounds the transaction. Because each tag costs its own queries, the registered model and model version specs stubMAX_TAGS_PER_CREATE_REQUESTto 2 to stay under the spec query limiter; theruns/createandexperiments/createspecs use the real 1,000-tag cap. - A spec pins the flag actor: enabling the flag for another project does not apply the limits to this project.
- No new per-user request-rate limit in this MR.
- Follow-ups, not in this MR: bulk-insert the tags in the two
add_metadataloops; Grape validation errors for malformedmetrics,paramsandtagsitems return{"error": ...}without an MLflowerror_code(already true formetricsandparams). - Docs: the rollout issue tracks documenting the limits on the MLflow client compatibility page before global rollout.
MLflow client compatibility
The MLflow Python client already splits log_batch calls into requests within these limits, and the MLflow tracking server enforces the same limits, so MLflow clients are not affected. A production data check found no request over these create caps; the details are in the confidential rollout issue.
MLflow source references (v3.17.2)
- Limits:
mlflow/utils/validation.py#L67-L70(MAX_PARAMS_TAGS_PER_BATCH = 100,MAX_METRICS_PER_BATCH = 1000,MAX_ENTITIES_PER_BATCH = 1000), checked by_validate_batch_log_limits(#L604-L614), which raisesINVALID_PARAMETER_VALUE. - Client chunking:
TrackingServiceClient.log_batch(mlflow/tracking/_tracking_service/client.py#L549-L589) chunks params and tags by 100 and metrics up to 1,000 items per request, and sends oneLogBatchcall per chunk. - Server enforcement: the SQLAlchemy store calls
_validate_batch_log_limits(mlflow/store/tracking/sqlalchemy_store.py#L2397-L2398). - Documentation: MLflow REST API, Log Batch, Request Limits.
create_run,create_experiment,create_registered_modelandcreate_model_versionsend all tags in one request. MLflow does not cap them, so with the flag on a client call fails above 1,000 tags (create_run,create_experiment) or 100 tags (create_registered_model,create_model_version).
References
- Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/635039
- Feature flag rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/635147
- Sibling MRs that edit the same files, independent of each other, any merge order: !263896 (
lib/api/ml/mlflow/api_helpers.rb,runs.rb,registered_models.rb, their request specs,doc/api/openapi/openapi_v3.yaml) and !263900 (spec/requests/api/ml/mlflow/runs_spec.rb). The hunks do not overlap; the MR that lands second rebases.
Screenshots or screen recordings
No UI change.
How to set up and validate locally
-
In a Rails console, enable the flag:
Feature.enable(:mlflow_enforce_request_limits). -
Create a personal access token with the
apiscope for a user with the Developer role on any project, then setTOKEN=<your_access_token>andMLFLOW=http://gdk.test:3000/api/v4/projects/<project_id>/ml/mlflow/api/2.0/mlflow. -
Create an experiment and a run:
curl --request POST --header "Authorization: Bearer $TOKEN" --header "Content-Type: application/json" \ --url "$MLFLOW/experiments/create" --data '{"name": "limits"}' curl --request POST --header "Authorization: Bearer $TOKEN" --header "Content-Type: application/json" \ --url "$MLFLOW/runs/create" --data '{"experiment_id": "<experiment_id>"}' -
Build a batch with 1,001 metrics and send it:
ruby -rjson -e 'puts({ run_id: ARGV[0], metrics: Array.new(1001) { |i| { key: "loss", value: 0.5, timestamp: 0, step: i } } }.to_json)' <run_id> > batch.json curl --request POST --header "Authorization: Bearer $TOKEN" --header "Content-Type: application/json" \ --url "$MLFLOW/runs/log-batch" --data @batch.jsonExpect: status 400 with
{"error_code":"INVALID_PARAMETER_VALUE","message":"A batch logging request can contain at most 1000 metrics. Got 1001 metrics. Please split up metrics across multiple requests and try again."}. -
Change
1001to1000and send again. Expect:{}with status 200. -
Run
Feature.disable(:mlflow_enforce_request_limits)and send the 1,001-metric batch again. Expect:{}with status 200.
More scenarios
- Tags on model creation, flag on:
curl ... --url "$MLFLOW/registered-models/create" --data "$(ruby -rjson -e 'puts({ name: "m1", tags: Array.new(101) { |i| { key: "t#{i}", value: "v" } } }.to_json)')". Expect 400 withA request can contain at most 100 tags. Got 101 tags.With 100 tags, expect 200.model-versions/create(withname: "m1") behaves the same.runs/createandexperiments/createbehave the same at 1,000 and 1,001 tags. - MLflow Python client, flag on:
MlflowClient().log_batch(run_id, metrics=[Metric("loss", 0.5, 0, i) for i in range(2500)])succeeds, because the client sends three requests of at most 1,000 metrics. - Malformed tags in
log-batch:"tags": [{"value": "v"}]returns 400tags[0][key] is missing, with or without the flag.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Database review
Not needed: the change validates request input before any query runs. It adds and changes no query, index or migration.