Files
qdrant/tests/openapi/test_memory_placement.py
Andrey Vasnetsov ab0d3ecc62 Add unified memory: cold|cached|pinned placement parameter for collection components (#9684)
* Add unified `memory: cold|cached|pinned` placement parameter for collection components

Introduce a single `memory` parameter that controls how each collection
component's data is held in RAM, replacing the inconsistent zoo of
`on_disk` / `always_ram` / `on_disk_payload` flags:

- `cold`: not pre-loaded from disk, cached with usage
- `cached`: pre-populated into page cache on load, evictable under pressure
- `pinned`: materialized on heap, never evicted by cache pressure

The parameter is available on dense vectors, HNSW config, all quantization
configs, the sparse index, all payload field index types, and payload
storage (as a new `payload: { memory }` sub-object on collection params).
When set, it overrides the deprecated legacy flag; when unset, behavior is
unchanged. Legacy flags are marked deprecated (Rust + proto) but keep
working; conflicts are resolved in favor of `memory` with a warning.

New capabilities enabled by the tri-state model:
- HNSW graph links can be pinned (first production caller of the existing
  `GraphLinksResidency::Pinned`)
- sparse mmap index, quantized vectors and on-disk payload field indexes
  gain a `cached` tier (mmap + populate on open)

`pinned` is rejected by API validation for components without a heap
variant (dense vector storage, payload storage). Low-memory mode degrades
placements at load time via `Memory::clamp_to_low_memory`, matching the
existing `prefer_disk`/`skip_populate` behavior. Effective-placement
comparison in the config-mismatch optimizer avoids spurious rebuilds when
the same placement is expressed through the new parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix gpu-gated tests for the new `memory` field

CI clippy runs with --all-features, which compiles the gpu-gated tests
that were missed locally: add the `memory` field to config literals and
allow deprecated placement params, same as in the rest of the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add OpenAPI tests for memory placement, keep sparse config downgrade-clean

- OpenAPI tests: create/update collections with `memory` on every component,
  assert the parameters are echoed in collection info, assert legacy-only
  collections expose no new fields, and assert `pinned` is rejected (422)
  for dense vector storage and payload storage on both create and update.
- Persist only the explicitly requested `memory` parameter in
  `sparse_index_config.json` instead of the legacy-resolved placement, so
  configurations using only the deprecated `on_disk` flag keep byte-identical
  files that older Qdrant versions load without unknown fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Validate collection meta ops at construction, not only in the API layer

The `memory: pinned` rejection for dense vectors and payload storage
lived in `Validate` impls on the internal request types, which only ran
through the REST actix extractor. gRPC validates just the proto message,
so a gRPC client could persist `pinned` where it is not supported and
have it silently treated as `cached`.

Run the derived validation in `CreateCollectionOperation::new` and
`UpdateCollectionOperation::new` instead: the constructors are the
common chokepoint for all API paths, before the operation is proposed
to consensus. This covers every validator on these types, not just the
`memory` checks, and keeps consensus-apply unaffected so mixed-version
clusters never reject already-committed operations.

`UpdateCollectionOperation::new` becomes fallible; `remove_replica` now
uses `new_empty` since it carries no user config. Regression tests drive
the gRPC conversion path and assert `InvalidArgument` for `pinned` on
create and update, with `cold`/`cached` accepted as a control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:32:51 +02:00

173 lines
5.8 KiB
Python

import pytest
from .helpers.collection_setup import drop_collection
from .helpers.helpers import request_with_validation
collection_name = "test_memory_placement"
@pytest.fixture(autouse=True)
def setup():
drop_collection(collection_name)
yield
drop_collection(collection_name)
def test_create_collection_with_memory_placement():
# Every component configured through the new `memory` parameter
response = request_with_validation(
api="/collections/{collection_name}",
method="PUT",
path_params={"collection_name": collection_name},
body={
"vectors": {
"size": 4,
"distance": "Dot",
"memory": "cached",
"hnsw_config": {"memory": "cold"},
"quantization_config": {
"scalar": {"type": "int8", "memory": "pinned"},
},
},
"sparse_vectors": {
"sparse": {"index": {"memory": "cold"}},
},
"payload": {"memory": "cached"},
},
)
assert response.ok, response.text
# The new parameters must be echoed back in the collection info
response = request_with_validation(
api="/collections/{collection_name}",
method="GET",
path_params={"collection_name": collection_name},
)
assert response.ok, response.text
params = response.json()["result"]["config"]["params"]
assert params["vectors"]["memory"] == "cached"
assert params["vectors"]["hnsw_config"]["memory"] == "cold"
assert params["vectors"]["quantization_config"]["scalar"]["memory"] == "pinned"
assert params["sparse_vectors"]["sparse"]["index"]["memory"] == "cold"
assert params["payload"]["memory"] == "cached"
# Payload field index with the new parameter
response = request_with_validation(
api="/collections/{collection_name}/index",
method="PUT",
path_params={"collection_name": collection_name},
query_params={"wait": "true"},
body={
"field_name": "keyword_field",
"field_schema": {"type": "keyword", "memory": "cold"},
},
)
assert response.ok, response.text
response = request_with_validation(
api="/collections/{collection_name}",
method="GET",
path_params={"collection_name": collection_name},
)
assert response.ok, response.text
schema = response.json()["result"]["payload_schema"]
assert schema["keyword_field"]["params"]["memory"] == "cold"
def test_create_collection_without_memory_placement_has_no_new_fields():
# A collection created without the new parameters must not expose them in
# the collection info, so clients of older SDKs see an unchanged response
response = request_with_validation(
api="/collections/{collection_name}",
method="PUT",
path_params={"collection_name": collection_name},
body={
"vectors": {"size": 4, "distance": "Dot", "on_disk": True},
},
)
assert response.ok, response.text
response = request_with_validation(
api="/collections/{collection_name}",
method="GET",
path_params={"collection_name": collection_name},
)
assert response.ok, response.text
params = response.json()["result"]["config"]["params"]
assert "memory" not in params["vectors"]
assert "payload" not in params
def test_pinned_dense_vector_storage_is_rejected():
# Dense vector storage has no heap variant: `pinned` must be rejected
response = request_with_validation(
api="/collections/{collection_name}",
method="PUT",
path_params={"collection_name": collection_name},
body={
"vectors": {"size": 4, "distance": "Dot", "memory": "pinned"},
},
)
assert response.status_code == 422, response.text
assert "pinned" in response.json()["status"]["error"]
def test_pinned_payload_storage_is_rejected():
response = request_with_validation(
api="/collections/{collection_name}",
method="PUT",
path_params={"collection_name": collection_name},
body={
"vectors": {"size": 4, "distance": "Dot"},
"payload": {"memory": "pinned"},
},
)
assert response.status_code == 422, response.text
assert "pinned" in response.json()["status"]["error"]
def test_update_collection_memory_placement():
response = request_with_validation(
api="/collections/{collection_name}",
method="PUT",
path_params={"collection_name": collection_name},
body={
"vectors": {"size": 4, "distance": "Dot"},
},
)
assert response.ok, response.text
# Vector storage placement and payload storage placement are mutable
response = request_with_validation(
api="/collections/{collection_name}",
method="PATCH",
path_params={"collection_name": collection_name},
body={
"vectors": {"": {"memory": "cold"}},
"params": {"payload": {"memory": "cached"}},
},
)
assert response.ok, response.text
response = request_with_validation(
api="/collections/{collection_name}",
method="GET",
path_params={"collection_name": collection_name},
)
assert response.ok, response.text
params = response.json()["result"]["config"]["params"]
assert params["vectors"]["memory"] == "cold"
assert params["payload"]["memory"] == "cached"
# `pinned` is rejected on update as well
response = request_with_validation(
api="/collections/{collection_name}",
method="PATCH",
path_params={"collection_name": collection_name},
body={
"vectors": {"": {"memory": "pinned"}},
},
)
assert response.status_code == 422, response.text
assert "pinned" in response.json()["status"]["error"]