License: MIT License
Small on-prem machine learning ecosystem for small-data environments, where fast iteration, maintainability, and reliable deployment matter more than large-scale infrastructure.
This repo is the deployment server side of the ecosystem.
It is designed to pair with the separate repo ml-packaging-toolbox, which produces the deployment artifact and can call the server's deploy/validate endpoints.
During my time in a small machine learning team, the deployment phase is consistently the bottleneck in our machine learning projects. Regardless of how good the modeling or signal processing pipelines were built, serving these models for real-time inference is what drives real value. Over time, I learned to utilize more tools and frameworks, and realized that most of our models are quite similar and the deployment can be standardized. Using cloud computing isn't an option for the system we're working with. External platforms still leave a lot of room when handling in-house/legacy data connectors and data processing pipelines — especially when we need to reconcile streaming data with long-term storage. At the end of the day, it's critical for us to quickly build an in-house platform to streamline these workflows and potentially allow new engineers to quickly develop and deploy new models. This project is a re-design of what I learned and would have done differently from my experiences in that project.
- Reduce operational debt from one-off model deployments
- Improve code reuse across domain-specific projects
- Support both near-real-time and batch inference workflows
- Guarantee training–serving feature parity through a shared dual-path preprocessing design
- Model registry: staged → validated → production lifecycle
- Deploy + validation API: upload model artifacts and run a smoke test
- Data collection API: store RT payloads in MongoDB and trigger async processing
- Async inference: Celery task aggregates multi-device data and scores with the production model
- Serving: synchronous prediction endpoint (JSON)
- Monitoring endpoints: counts/status snapshots for models/data/inference
These terms appear throughout the codebase, API routes, and documentation. They are intentionally generic so you can map them to whatever your domain calls the same concepts.
| Term | Definition | Adapt it to your domain |
|---|---|---|
| Unit | A single item being measured or processed — one row of sensor readings, one test result, one part off the line. | wafer, sample, component, patient visit, transaction |
| Batch | A group of units processed together in one production run or campaign. Used as the grouping key (batch_id) for data quality filters during model training. |
lot, run, shift, campaign, cohort |
| material_id | An identifier that ties together all sensor/device readings for the same physical unit across multiple devices or events. Pass it as a query param on /data/collect so the inference task knows which readings belong together. If you only have one device, omit it — the server falls back to dc_id. |
wafer ID, part serial number, patient ID, order number |
| dc_id | Data Collection ID. A UUID assigned by the server to each individual /data/collect call. Use it to poll /inference/result/<dc_id> for the prediction that was triggered by that collection. |
event ID, reading ID |
| Deploy / Deployment | The act of uploading a model artifact (zip) to the server and registering it in the model registry. A freshly deployed model has status staging. It only becomes production after passing the smoke test (/deploy/validate). |
model registration, model upload |
| Validation | An automated smoke test run against a small held-out dataset bundled inside the deployment artifact. Promotes the model to production on pass. Keeps it in staging on fail so bad models never serve live traffic. |
acceptance test, integration test |
| Data Collection | Posting a real-time sensor or device payload to /data/collect. The server stores the raw features, gates on the SQLite dispatch cache (is there a production model that needs this device/event?), and queues an async inference task. |
measurement event, sensor push, data ingest |
| Postmeas | Post-measurement collection via /postmeas/collect. Used for QA or validated data that arrives after the primary production event — for example, a final test result that comes in minutes or hours after the in-line sensor data. |
final test, end-of-line QA, lab result |
| Inference | The async background process (Celery task) that checks whether all required device/event data for a given material_id has arrived, merges the features, loads the production model, and writes the scored result to MongoDB. |
scoring, prediction, background job |
| Serve / Serving | The synchronous /serve/predict endpoint that scores a single payload immediately and returns a JSON prediction in the same HTTP response. Use this when you need a result before returning to the caller, rather than polling for an async result. |
real-time inference, online prediction, sync scoring |
| Service | Min version | Notes |
|---|---|---|
| Python | 3.10 | |
| MongoDB | 6.0 | required for the deployment server |
| Redis | 7.0 | required for the deployment server |
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt1. Start MongoDB
Option A (local service):
mongod --dbpath ./data/dbOption B (Docker):
docker run --name ml-mongo -p 27017:27017 -d mongo:62. Configure the instance
Edit instance/config.py (git-ignored) with your SECRET_KEY, MONGO_URI, and Redis URLs.
3. Initialise databases (run once)
python setup/setup_mongo.py # TTL + query indexes in MongoDB
python setup/setup_sqlite3.py # dispatch cache in SQLite34. Start the Celery worker (separate terminal) Option A (Linux):
celery -A celery_worker worker --loglevel=infoOption B (Windows):
Windows does not support forking and must use spawn to create new worker processes. Instead, run:
celery -A celery_worker worker --loglevel=info --pool=threads5. Start the development server
python runserver.py
# API available at http://127.0.0.1:5000examples/demo_mongodb_collect_and_infer.py: POST/data/collectthen poll/inference/result/<dc_id>examples/demo_multi_device.py: multi-device session collection flow (requires the server to be running)
pip install -r requirements-test.txt
python -m pytest tests/ -vgunicorn -c gunicorn.conf.py "webservice:create_app()"Point Nginx at 127.0.0.1:8000. Never expose Gunicorn directly on 0.0.0.0.
# 1. Register a pkl model with its context (which device/event it needs)
curl -X POST http://localhost:5000/deploy/ \
-F "model_file=@clf.pkl" \
-F "name=clf" -F "version=1.0.0" \
-F 'context_yaml=required_contexts:
- device_id: deviceA
event_code: EVT_001
features: [feat1, feat2]'
# → {"model_id": "<id>", "status": "staging"}
# 2. Validate (promotes to production on pass)
curl -X POST http://localhost:5000/deploy/validate/<id>
# → {"passed": true, ...}
# 3. Collect real-time data — inference runs automatically in background
curl -X POST "http://localhost:5000/data/collect?device_id=deviceA&event_code=EVT_001&material_id=BATCH001" \
-H "Content-Type: application/json" \
-d '{"feat1": 1.0, "feat2": 2.5}'
# → {"dc_id": "<dc_id>", "status": "accepted"}
# 4. Poll for the scored result
curl http://localhost:5000/inference/result/<dc_id>
# 5. Or get a synchronous prediction directly
curl -X POST http://localhost:5000/serve/predict \
-H "Content-Type: application/json" \
-d '{"device_id": "deviceA", "features": {"feat1": 1.0, "feat2": 2.5}}'ml-deployment-ecosystem/
│
├── webservice/ # ml-deployment-server (Flask app)
│ ├── __init__.py # App factory
│ ├── extensions.py # PyMongo, Flask-Caching, Celery
│ ├── utils.py # JSON payload helpers, feature reconciliation
│ └── mod/
│ ├── deploy/ # POST /deploy/ + /deploy/validate/<id>
│ ├── data/ # POST /data/collect + /postmeas/collect
│ ├── inference/ # GET/POST /inference/ + async task
│ ├── serve/ # POST/GET /serve/predict
│ └── deployment/ # GET /deployment/ monitoring
│
├── setup/
│ ├── setup_mongo.py # MongoDB TTL + query indexes (run once)
│ └── setup_sqlite3.py # SQLite3 schema for dispatch cache (run once)
├── tests/ # Server test suite (pytest + mongomock)
├── instance/ # Machine-specific config — NOT in VCS
├── config.py # Default server config
├── runserver.py # Dev server entry point
├── celery_worker.py # Celery worker entry point
└── gunicorn.conf.py # Production Gunicorn config
The central design principle in ml-packaging-toolbox is that every data pipeline object implements two processing paths.
┌─────────────────────────────┐
│ DataAssemblyPipeline │
└──────────┬──────────┬────────┘
│ │
┌───────────────────▼──┐ ┌───▼───────────────────────┐
│ LT path │ │ RT path │
│ (model training) │ │ (inference reconciliation)│
└───────────┬──────────┘ └───────────┬────────────────┘
│ │
extract_lt(**params) extract_rt(live_payload)
→ raw DataFrame → normalised dict
│ │
chain.fit_transform_lt() chain.transform_rt()
→ preprocessed DataFrame → reconciled feature dict
│ │
train/test split {feat: value, ...}
→ X_train, X_test, ... ready for model.predict()
Why this matters: the preprocessing statistics (quantile bounds, column means/stds, fill values) are fitted once on the LT training data. The same fitted objects are applied to every real-time record in reconcile_task. This eliminates training–serving skew.
class BaseExtractor:
def extract_lt(self, **kwargs) -> pd.DataFrame:
# batch pull from long-term storage (SQL, file, API)
def extract_rt(self, payload: dict) -> dict:
# normalise a single RT payload to match LT field schemaclass BasePreprocessor:
def fit(self, X: pd.DataFrame) -> BasePreprocessor:
# learn parameters from LT training data
def transform_lt(self, X: pd.DataFrame) -> pd.DataFrame:
# apply to LT batch (training / validation)
def transform_rt(self, record: dict) -> dict:
# apply fitted parameters to a single RT record (inference)ModelPackager.build() produces a zip containing:
| File | Consumed by |
|---|---|
model.pkl |
/deploy/ — stored; inference_task loads for scoring |
preprocessors.pkl |
reconcile_task — applies fitted chain to RT payload |
context.yml |
/deploy/ registration + context store |
features.json |
inference_task — ordered feature alignment |
test_data.json |
/deploy/validate/<model_id> smoke test |
Contract rule: any change to the zip structure in
packager.pyrequires a matching change inwebservice/mod/deploy/routes.pyandwebservice/mod/inference/tasks.py.
- Model artifacts: stored in MongoDB (
models.pkl_bytes) rather than written to a deployment directory. - Inference outputs: stored as JSON (
inference.json_output).
Models are not tagged with a device name. Instead, each model declares its required contexts: a list of {device_id, event_code, features} entries stored in the contexts collection (contexts._id == models._id).
This means:
- A single model can require data from multiple devices/events before scoring
POST /data/collectdispatches via a SQLite lookup (O(1)) — no MongoDB round-trip needed for the fast pathinference_taskfinds all models that need a given(device_id, event_code), checks whether all required data for the samematerial_idhas arrived, then scoresPOST /serve/predictlocates the production model by querying contexts fordevice_id, then scores synchronously
When data arrives from multiple devices for the same physical unit, they share a material_id query param on /data/collect. The inference task uses material_id to gather all required device/event docs before scoring. Single-device flows use dc_id as a fallback material_id.
| Collection | TTL | Purpose |
|---|---|---|
| models | none | Model artefacts + lifecycle (staging/production) |
| contexts | none | required_contexts per model; _id == model OID |
| data | 3 days | RT payloads + reconciled features per collection |
| request | 3 days | Per-request audit log (params, not payload) |
| validate | 3 days | Smoke-test results |
| inference | 14 days | Scored results + predicted JSON |
| validateddata | 14 days | Post-hoc QA feature snapshots |
SQLite3 holds a contexts dispatch cache: (device_id, event_code) pairs for all production models. Populated on model promotion, queried on every /data/collect for a sub-millisecond gate.
- Flask-Caching on serving endpoints (60 s TTL, manual cache key)
- Celery broker (DB1) + result backend (DB2)
POST /data/collect → inference_task
→ find all contexts matching (device_id, event_code)
→ for each: check all required data for material_id is present
→ load model by ObjectId, merge features, score
Sits behind Nginx. Never expose 0.0.0.0 in production.
| Lane | Blueprint | Endpoint |
|---|---|---|
| Deployment | deploy | POST /deploy/ |
| Deployment | deploy | POST /deploy/validate/<model_id> |
| Data Collection | data | POST /data/collect?device_id=<id>&event_code=<code>[&material_id=<id>] |
| Data Collection | postmeas | POST /postmeas/collect |
| Inference | inference | GET /inference/result/<dc_id> |
| Inference | inference | POST /inference/trigger/<dc_id> |
| Serving | serve | POST /serve/predict (returns JSON) |
| Serving | serve | GET /serve/predict/<dc_id> (cached) |
| Monitoring | deployment | GET /deployment/ + /models /data /inference |
- Not for high-volume production traffic
- Data >2 min late: re-queue via
POST /inference/trigger/<dc_id> preprocessors.pklloading inreconcile_taskis not yet wired — planned next