Skip to content

How To Use A Deployment Schema#

Introduction#

In this guide, you will learn how a deployment describes the prediction requests it accepts, how clients read that contract, and how to serve a model without writing a predictor script.

A deployment schema lists the fields a client sends with each request, with their types, nullability, and order, plus the shape of the response. It is inferred from the feature view the model was registered with: the serving keys, the features you pass with the request, the request parameters of on-demand transformations, and the extra columns of feature logging. It is published as a JSON Schema and an OpenAPI document, so clients in any language can validate requests before sending them. Every REST V1 request is validated in the pod before any predictor code runs, and rejected with a structured error when it does not match. Enforcement covers the KServe REST V1 protocol only: a gRPC deployment is served without it, and the pod logs a warning at startup.

The default predictor is the library class that serves such a deployment: it looks up and transforms the features by serving key, runs the model, and logs the request when the feature view has logging enabled. A feature view can be deployed on its own with the same class and the same contract, returning the transformed feature vector instead of a prediction.

Code#

Step 1: Connect to Hopsworks#

import hopsworks


project = hopsworks.login()

fs = project.get_feature_store()
mr = project.get_model_registry()

Step 2: Register the model with its feature view#

Register the model with feature_view= so the deployment knows where its features come from. The training dataset version is taken from the training dataset you last read or created with that feature view in this session, and the model schema is inferred from the feature view's training dataset schema.

feature_view = fs.get_feature_view("transactions", version=1)
X_train, X_test, y_train, y_test = feature_view.train_test_split(test_size=0.2)

# ... train and pickle the model into model_dir ...

model = mr.python.create_model(
    name="fraud",
    feature_view=feature_view,
)
model.save("model_dir")

The default predictor loads a single .pkl, .pickle, or .joblib file from the model directory.

Step 3: Deploy without a predictor script#

deployment = model.deploy(
    name="fraud",
    passed_features=["amount"],  # features the client sends with each request
)
deployment.start(await_running=600)

The default predictor is used when the model is a Python model registered with a feature view, no script_file or transformer is given, and the deployment uses KServe over REST. Pass default_predictor=True to force it, for instance for a scikit-learn model, or default_predictor=False to keep the plain model server.

At pod start the predictor checks that every input column of the model schema is served by the feature view, with a compatible type. A mismatch fails the deployment with the offending columns in deployment.get_logs(), instead of serving wrong predictions.

If the feature view has a transformation that needs training dataset statistics, such as min_max_scaler, and the model has no training dataset version, deploy() refuses and names the transformation.

Step 4: Read the contract#

schema = deployment.schema
schema.describe()  # one row per field: group, name, type, nullable

print(schema.names)  # the order of positional rows
print(schema.unresolved)  # fields whose type is not known, such as request parameters

json_schema = schema.to_json_schema()  # {"request": ..., "response": ...}
openapi = schema.to_openapi("fraud", url=deployment.get_inference_url())

Fields belong to one of four groups:

group source required
serving keys the feature view's serving keys, present only when a feature is looked up yes, non-null
passed features passed_features= yes, nullable when the feature is
request parameters arguments of on-demand transformations that are not features yes
extra logging features extra columns of the feature view's logging, minus the reserved ones no

Request parameter types come from the annotations of the transformation function's arguments: def amount_ratio(amount: float, budget: float) records budget as double. An unannotated argument is reported as unresolved, and any value is accepted for it unless you refine the schema (Step 6).

Step 5: Send requests#

Rows are objects keyed by field name, or arrays in schema.names order. A request holds one to schema.max_batch_rows rows (default 512). The limit is part of the schema, so the published JSON Schema, the client, the transformer, and the predictor all apply the same one. Set SERVING_MAX_BATCH_ROWS in env_vars= to change it; the change publishes a new schema id. A request carries either instances or inputs, never both.

deployment.predict(inputs=[{"cc_num": 4473593503484549, "amount": 12.5}])
deployment.predict(inputs=[[4473593503484549, 12.5]])

The client validates the rows against the schema before sending and raises ModelServingException with every problem found; pass validate=False to skip that and let the pod answer. A batch is all objects or all arrays, in the published JSON Schema as in the pod.

# INFERENCE_URL is deployment.get_inference_url(), also shown on the deployment page
curl -X POST "$INFERENCE_URL" \
  -H "Authorization: ApiKey $API_KEY" -H "Content-Type: application/json" \
  -d '{"instances": [{"cc_num": 4473593503484549, "amount": 12.5}]}'

Step 6: Refine or replace the schema#

Pass schema= to deploy() to refine the inferred schema, for instance to give a request parameter a type. A refinement keeps the inferred fields; adding or removing one is refused.

from hsml.deployment_schema import DeploymentSchema

deployment = model.deploy(name="fraud", passed_features=["amount"])
inferred = deployment.schema
refined = DeploymentSchema(
    serving_keys=inferred.serving_keys,
    passed_features=inferred.passed_features,
    request_parameters=[{"name": "rate", "type": "double", "nullable": False}],
    extra_logging_features=inferred.extra_logging_features,
    feature_view=inferred.feature_view,
    training_dataset_version=inferred.training_dataset_version,
    output=inferred.output,
)
deployment.schema = refined
deployment.save()

A custom predictor script deployed with schema= (or passed_features=) gets the same validation in the pod, before its predict() is called, for REST V1 requests. The client validates REST requests only, so a gRPC client is not checked on either side.

Step 7: Republish after changing the feature view#

The served contract does not change when you enable logging, add logging columns, or change the feature view. Re-infer and save to publish the new contract as a new revision:

deployment.reinfer_schema()
deployment.save()

Revisions#

Every schema is content-addressed: deployment.schema_id is a hash of its content, and equal schemas have equal ids. The client writes the schema and its JSON Schema and OpenAPI renderings to /Deployments/<deployment-name>/resources/schema/<schema_id>.* before the deployment is created or updated, and records the id in the environment variable SERVING_SCHEMA_ID of the predictor and of the transformer, when there is one. SERVING_SCHEMA_ENFORCER on the same components records which of the two validates requests for that revision. The files are never modified or removed while the deployment exists.

A deployment revision therefore always enforces the exact schema it was created with, and answers discovery consistently with what it enforces. The pod also takes its model, feature view, and training dataset version from its own revision, and refuses to start when the schema was published for another training dataset version than the one it would serve. Updating the schema rolls the instances: old pods keep the old contract until they are replaced. Rolling back is deployment.schema = previous_schema; deployment.save(), which points the revision at a file that is still there.

Discovery for non-Python clients#

The Hopsworks REST API serves the three documents to any client with an API key that has the SERVING scope, so prediction access implies discovery access:

SERVING_ID=$(curl -s -H "Authorization: ApiKey $API_KEY" \
  "https://$HOST/hopsworks-api/api/project/$PROJECT_ID/serving?name=fraud" | jq .id)

curl -s -H "Authorization: ApiKey $API_KEY" \
  "https://$HOST/hopsworks-api/api/project/$PROJECT_ID/serving/$SERVING_ID/schema?format=openapi"

format is schema (default), jsonschema, or openapi. schemaId=<id> returns the documents of an earlier revision. A deployment without a schema, or an unknown id, answers 404 with error code 240037.

Type encoding#

The JSON Schema fragment, the accepted JSON values, and the encoding the Python client applies follow one table.

feature type JSON Schema accepted JSON Python client sends
tinyint, smallint, int {"type": "integer"} integer int
bigint integer or decimal string integer, or a decimal string for values beyond 2^53 int
float, double {"type": "number"} finite number int, float
decimal(p,s) number or string number, or decimal string Decimal as string
string, varchar(n), char(n) {"type": "string"} string str
boolean {"type": "boolean"} boolean bool
timestamp RFC 3339 string or integer RFC 3339 string, or epoch milliseconds datetime as RFC 3339 UTC
date date string or integer YYYY-MM-DD, or days since epoch date as YYYY-MM-DD
binary base64 string base64 string bytes as base64
array<T> array of T array list
struct<...> object with exactly those fields object dict
map<K,V> object with values of V object dict
unresolved {} anything unchanged

Errors#

Errors raised by the default predictor and by the schema enforcement carry a structured detail:

{"detail": {
  "code": "SCHEMA_VALIDATION",
  "message": "Prediction request does not match the deployment schema of 'fraud'.",
  "schema_id": "3f9a1c2b7d4e6f80",
  "errors": [
    {"row": 0, "field": "amount", "reason": "missing"},
    {"row": 2, "field": "cc_num", "reason": "must not be null"}
  ]}}
status code when
400 SCHEMA_VALIDATION the request does not match the schema; errors names every row and field
400 FEATURE_LOOKUP_FAILED the feature store rejected the lookup for a reason other than a missing entity
404 ENTITY_NOT_FOUND at least one row's serving keys match no entity; the batch is rejected and errors names the rows
413 BATCH_TOO_LARGE more than SERVING_MAX_BATCH_ROWS rows
422 TRANSFORMATION_FAILED a transformation raised; field is the transformation name
500 MODEL_FAILED the model raised
500 CONTRACT_VIOLATION the pod produced a result that does not match the contract: a different number of vectors or predictions than rows, or other feature vector columns than published
503 FEATURE_STORE_UNAVAILABLE the online store or the feature store API could not be reached

A request is all or nothing: either every row gets a prediction, in request order, or the whole request fails and no row is logged. Error responses never include feature values or exception text: a failure names the exception type only, and detail.request_id carries the correlation id (the x-request-id header, or one generated for the request) under which the pod log holds the full error. The Hopsworks REST inference proxy does not forward x-request-id; send it through the Istio URL when the id must be yours. Responses with a 5xx status may be retried; a retry may read newer features and always produces another log row, so reuse the x-request-id header to tie the rows together.

Feature logging and monitoring#

When the feature view has logging enabled, the default predictor logs every request with the untransformed and transformed features, the predictions, the request id, the training dataset version, and the model name and version, so deployment.create_model_monitoring() works with no extra code.

Declare the reserved extra logging columns on the feature view and the predictor fills them, which tells deployments and revisions apart in the log:

column type value
deployment_name string the deployment name
deployment_version int the deployment version
deployment_schema_id string the schema id of the revision that served the request
request_row int the row's index in its request

Any other extra logging column becomes a request field that clients may send.

Logging is asynchronous: the request is answered immediately, the logging frame is built on a background thread of the predictor, and the rows are handed to the pod's inference-logger sidecar from there. A logging failure never fails a request, and both buffers are bounded by FEATURE_LOGGER_QUEUE_SIZE rows (default 1000: rows waiting for the predictor's logging thread, and rows waiting in the sidecar logger); beyond it a request's rows are dropped and counted, so a slow logger cannot exhaust the pod's memory. Both buffers count rows rather than requests, because one request carries a whole batch. The predictor's own backlog admits one request whatever its size when it is empty, so a deployment whose batches are larger than the buffer logs instead of dropping every request; its peak is then that single batch, itself capped by the schema's batch limit. There is no synchronous mode: a prediction is never delayed by its log write.

Custom predictor scripts#

Subclass the default predictor when the model needs another loader or the predictions need post-processing, and deploy with default_predictor=True so the schema is still inferred:

from hsml.default_predictor import DefaultPredict


class Predict(DefaultPredict):
    def load_model(self, model_files_path): ...

    def model_predict(self, feature_vectors):
        return self.model.predict_proba(feature_vectors[self.model_input_columns])

The serving wrapper imports a model deployment's script itself, so the script needs no __main__ block. Only a feature view deployment script, which may be started as a plain script, hands over to the wrapper. Any predictor script, subclass or not, is protected by the serving wrapper when the deployment carries a schema: invalid rows and oversize batches are refused before predict() runs. With a transformer, the transformer validates the request, whether or not it implements preprocess(), and the predictor trusts the transformer's output. Each pod reads that role from its own revision, so a predictor created before a transformer was added keeps validating until it is replaced. This needs an inference environment built from a Hopsworks 5.1 or later base image; an older image serves the deployment without checking.

Deployments without lookups#

Nothing is looked up in the online store when every stored feature of the view arrives with the request, or when the model has no feature view. In both cases the schema has no serving keys.

Every feature passed#

Pass every non-label stored feature of the view in passed_features=; on-demand features are computed from the request parameters, so they are never looked up. The view still computes the on-demand features and applies the model-dependent transformations with the pinned training dataset's statistics, and feature logging and monitoring work as for any other deployment.

stored = [
    f.name
    for f in feature_view.features
    if not f.label and f.on_demand_transformation_function is None
]
deployment = model.deploy(name="fraud_passed", passed_features=stored)
print(deployment.schema.serving_keys)  # []

The same applies to feature_view.deploy(passed_features=stored), which then returns the transformed vectors of the passed features.

A model without a feature view#

A Python model registered without a feature view deploys with the default predictor when you pass default_predictor=True and name its input columns with passed_features=, in the order the model expects. Those columns are the whole request: the schema has no serving keys and no request parameters, nothing is looked up or transformed, and there is no feature logging or monitoring because there is no feature view. The types are unresolved, so any JSON value is accepted for them; refine the schema with schema= to pin them down.

model = mr.python.create_model(name="fraud_plain")
model.save("model_dir")

deployment = model.deploy(
    default_predictor=True,
    passed_features=["amount", "age_days"],  # the model's input columns, in its order
)
deployment.schema.describe()  # passed features only, types unresolved
deployment.predict(inputs=[{"amount": 12.5, "age_days": 41}])

Access control#

Prediction through the Hopsworks REST API and through the Istio ingress requires an API key with the SERVING scope and the Data Owner or Data Scientist role in the project. The pod looks up features as the project's serving identity, not as the caller. Anyone allowed to call :predict can therefore obtain the transformed features of any entity the feature view can serve, and a feature view deployment returns those features directly. Log rows contain feature values and are governed by the logging feature group's permissions.

Environment variables#

variable set by meaning
SERVING_SCHEMA_ID the client the schema the revision serves
SERVING_FEATURE_VIEW_NAME, SERVING_FEATURE_VIEW_VERSION the client, feature view deployments the feature view served
SERVING_TRAINING_DATASET_VERSION the client, feature view deployments the pinned training dataset
SERVING_SCHEMA_ENFORCER the client predictor or transformer: the component of the revision that validates requests
SERVING_MAX_BATCH_ROWS you, through env_vars= rows accepted per request, default 512; recorded in the schema at publication
FEATURE_LOGGER_QUEUE_SIZE you, through env_vars= rows the predictor's logging thread and the async logger each buffer before dropping, default 1000; a value that is not a positive integer is ignored

The SERVING_* names are reserved and refused in env_vars=, except SERVING_MAX_BATCH_ROWS.

API Reference#

hsml.deployment_schema.DeploymentSchema

hsml.default_predictor.DefaultPredict

Model.deploy

Deployment