GitHub Repository: https://github.com/serc-project/stream-sync
- Project Overview
- Requirements
- Stakeholder Identification
- Architecture Design
- Architectural Tactics
- Subsystem Overview
- Implementation Patterns
- Architecture Analysis
- Individual Contributions
StreamSync is a microservices-based movie recommendation and social tracking platform. It solves the fragmentation problem of modern streaming: users subscribe to many OTT services but have no single place to track what they have watched, discover what to watch next, or coordinate viewing with friends.
The system combines:
- A unified media tracker that logs ratings and watch history across services.
- A sequence-aware ML recommendation engine powered by a State Space Model (SSM/Mamba) that evolves a compact per-user latent vector over their chronological watch history.
- A semantic "vibe" search interface backed by sentence-transformer embeddings and FAISS approximate-nearest-neighbour retrieval.
- A Watch Party Blend feature that combines multiple users' SSM states into a group consensus recommendation, filtering out titles already seen by any participant.
- A social layer (follows, friend feeds, public profiles) for taste comparison and group coordination.
The runtime is fully containerised. Seven independently deployable units are orchestrated via Docker Compose: a React SPA, a Spring Cloud Gateway, four Spring Boot Java microservices, and one Python FastAPI ML microservice.
| # | Requirement | Evidence in Codebase |
|---|---|---|
| FR-1 | Unified Media Tracker — Users must be able to log, update, and remove watched movies with optional 0–10 ratings, and maintain a separate plan-to-watch (watchlist). | UserProfileController — POST /watched-movies, PUT /watched-movies/{id}, DELETE /watched-movies/{id}, POST /watchlist/{tmdbId} |
| FR-2 | Sequence-Aware Recommendations — The system must generate personalised movie recommendations by continuously evolving a per-user SSM state vector that encodes the chronological sequence of their watch history. | UserSsmStateService.updateUserStateFromWatchedMovie() calls POST /update_state; getRecommendedMovieIds() calls POST /search-by-state on the ssm-engine. |
| FR-3 | Semantic "Vibe" Search — Users must be able to query movies using free-form natural language (e.g., "claustrophobic sci-fi with a twist") instead of exact titles or genres. | POST /semantic-search in app.py; POST /api/movies/semantic-search in MovieController. Fallback to SQL LIKE search if ssm-engine is unavailable. |
| FR-4 | Watch Party / Blend — The system must accept a list of ≥ 2 usernames, combine their individual SSM states into a group preference, and return a consensus recommendation list that excludes movies all participants have already seen. | WatchPartyService.getWatchPartyRecommendations() → POST /combine-states on ssm-engine. |
| FR-5 | Social Layer — Users must be able to follow others, view mutual follow (friend) relationships, and query any public profile's watch history. | UserProfileController — POST /follow/{friendName}, POST /unfollow/{friendName}, GET /followers, GET /{username} with public/private visibility gate. |
| FR-6 | Movie Catalogue — The system must maintain a searchable Postgres catalogue of movies (title, genres, cast, director, ratings, poster) sourced from the Kaggle/TMDB dataset. | MovieCatalogBootstrapLoader streams NDJSON from ssm-engine /movie-database-stream and bulk-inserts into Postgres via batchUpdate. |
| FR-7 | Scheduled Catalogue Refresh — The movie catalogue must refresh automatically every 24 hours so it stays in sync with the latest TMDB snapshot. | @Scheduled(fixedDelayString = "${movie.catalog.refresh.interval-ms:86400000}") in MovieCatalogBootstrapLoader.scheduledRefresh(). |
| FR-8 | SSM State Persistence — Per-user SSM state vectors must survive service restarts by being persisted to a dedicated Postgres table. | user_ssm_state table with UPSERT in UserSsmStateService; Docker volume ssm-data for FAISS artifacts. |
| FR-9 | Authentication & Access Control — All user-mutation endpoints must verify a Bearer JWT token, and data access must be scoped to the token owner. | extractUsername() in UserProfileController; isOwnedRequest() guard on every write endpoint; auth-service issues tokens via /api/auth/**. |
| FR-10 | Movie Detail Enrichment — Single-movie and batch-movie metadata must be retrievable for display, with a SQL fallback when the ML engine is down. | MovieQueryService.findByTmdbId(), findByTmdbIds(), findMostPopular(), findHighestRated(), search(). |
| # | Requirement | Metric / Target | Architectural Significance |
|---|---|---|---|
| NFR-1 | Low-Latency ML Inference | < 300 ms for core recommendation and search queries | Prevents the use of full-context transformers at query time. Enforces the use of the Mamba/S4D State Space Model (linear recurrence, O(L) not O(L²)) and FAISS IndexFlatIP for sub-millisecond ANN search. The SSM state is maintained as a compact floating-point tensor stored in Postgres; only a single step() forward pass is needed per new watch event. |
| NFR-2 | Decoupled Scalability | ML workloads must scale independently of CRUD operations | Mandates a microservices architecture with strict service boundaries. Java Spring Boot containers handle transactional user/movie CRUD. Python FastAPI (ssm-engine) handles all PyTorch inference and FAISS operations. They communicate only over HTTP REST, allowing each tier to be replicated or replaced independently. |
| NFR-3 | Fault Tolerance / ML Fallback | System must remain functional even if ssm-engine is unreachable |
Implements the Strategy Pattern in MovieController.semanticSearch(): if ssmEngineClient.semanticSearch() returns empty, the system transparently falls back to movieQueryService.search() (SQL LIKE). Similarly, UserSsmStateService catches exceptions from the ssm-engine to avoid propagating 5xx to the end user on watch-event updates. |
| NFR-4 | Asynchronous, Non-Blocking Index Rebuild | Rebuilding the ~900k-row FAISS index must not block API traffic | The MovieCatalogBootstrapLoader uses Java's HttpClient streaming (HttpResponse.BodyHandlers.ofLines()) to consume NDJSON chunks, decoupled from the main request thread. The ssm-engine tracks _rebuild_in_progress state and exposes a /build-status endpoint; Docker volume ssm-data ensures the index persists across container restarts. |
| NFR-5 | Data Isolation | Each microservice owns its own persistent store | Two separate Postgres instances (movie-db on port 5432, recommendation-db on port 5433) with independent schemas. User profiles are stored in a MongoDB-style document (JPA @Document) within the user-service. This prevents cross-service schema coupling and allows independent DB upgrades. |
| NFR-6 | GPU Utilisation & Adaptive Batching | GPU usage heavily spikes for ~30 minutes during daily FAISS indexing; inference uses minimal GPU compute | build_index.py handles the daily heavy-lifting (the 30-minute semantic re-index of ~900k titles) using adaptive batch size reduction on CUDA OOM. Outside this indexing window, the PyTorch SSM forward passes (step()) and FAISS fast inner-product search consume very little GPU payload, making it possible to run on mid-tier hardware (e.g., RTX 2060). |
| NFR-7 | Security (RBAC + JWT) | No user may mutate another user's data | Every write endpoint in UserProfileController calls isOwnedRequest() which validates the Authorization: Bearer <JWT> header against the path-variable username. Tokens are HMAC-SHA256 signed with a configurable secret. Auth service is a separately deployable container. |
| NFR-8 | Recommendation Quality | Recommendations must not be dominated by obscure or low-vote movies | Quality-aware re-ranking in _quality_score() blends semantic cosine similarity (weight 0.50) with IMDB/TMDB rating (0.20), log-normalised TMDB popularity (0.15), and log-normalised vote count (0.15). Over-fetches 5× top_k candidates from FAISS, then re-ranks in memory. |
| NFR-9 | High Availability | 99.9% API uptime, maintaining operation during deep ETL processing | Achieved through: (1) in-memory hot swapping of the FAISS index during rebuilds; (2) service decoupling so auth and user tracking are unaffected by ML latency; (3) Strategy fallbacks to SQL LIKE queries when the ML engine fails; and (4) non-destructive ON CONFLICT DO UPDATE upserts during scheduled TMDB catalogue streaming. |
| Stakeholder | Role / Interest | Concerns |
|---|---|---|
| Casual Viewers & Enthusiasts | Primary end users seeking personalised discovery and persistent cross-platform history | Unified watch history across OTT services; recommendations that evolve with long-range taste patterns; vibe-based natural-language search |
| Friend Groups & Families | Groups coordinating shared viewing sessions | Conflict-free group recommendation (Watch Party Blend); real-time OTT availability per title |
| Content Reviewers | Users maintaining immutable rating logs and engaging socially | Persistent review log; social features (follow, taste comparison, notifications) |
| Frontend Lead | Owns React SPA, UI/UX, client-side integration | Clear, stable REST API contracts between frontend and backend |
| Backend Lead | Owns Spring Boot services, JWT auth, PostgreSQL schema | Maintainability of service boundaries; correct domain model shared across services |
| ML / Data Lead | Owns FastAPI service, Mamba SSM, vector embeddings, TMDB pipeline | ML inference latency (< 300 ms); correct normalisation of TMDB metadata; reproducible sequential data format for Mamba training |
| Arch / Integration Lead | Owns Blend algorithm, TMDB Adapter, inter-service communication, QA | Independent deployability of ML vs. core services; graceful degradation when ML service is unavailable; testability of integration boundaries |
| System Architect | Responsible for overall coherence and non-functional properties | Security and data ownership; scalability; loose coupling between services |
mindmap
root((StreamSync))
Auth & Identity
JWT issuance and validation
Role-Based Access Control
Unified Tracker
Watch log CRUD
OTT availability lookup via TMDB Adapter
Recommendation Engine
Primary: Mamba SSM via FastAPI <300ms
Fallback: genre/rating filter Strategy pattern
Semantic Search
Natural-language query to vector embedding
Cosine similarity over overviews, themes, tags
Blend Engine
Aggregate histories and PTW lists per group
Filter already-seen titles per member
Score candidates across group preferences
Observer: notify members when Blend is ready
Social Layer
Follow graph and friend feed
Taste profile comparison
Blend invitations and notifications
graph TD
Browser["Browser\n(React SPA)"]
subgraph Gateway["Spring Boot Gateway"]
Auth["AuthController\nJWT issuance + RBAC"]
Tracker["TrackerController\nWatchEntry CRUD"]
Blend["BlendController\nWatchGroup + Blend lifecycle"]
Social["SocialController\nFollow graph, friend feed"]
Search["SearchController"]
end
subgraph ML["FastAPI ML Service"]
Recommend["/recommend\nMamba SSM"]
BlendAlgo["/blend\nBlend algorithm"]
VectorSearch["/search\nVector search"]
end
PostgreSQL[("PostgreSQL\nusers, watch_entries,\nmedia_items, blends")]
TMDB["TMDB API\nMetadata + OTT availability"]
Browser -->|"HTTPS / REST\nJWT Bearer"| Gateway
Search -->|REST| ML
Gateway -->|Spring JPA| PostgreSQL
Gateway -->|TMDB Adapter| TMDB
| Pattern | Where | Why |
|---|---|---|
| Strategy | Recommendation Engine | Swaps Mamba SSM for a simpler filter at runtime if the ML service is down |
| Adapter | TMDB integration | Isolates the domain model from TMDB JSON schema changes; produces typed MediaItem / OTTAvailability objects |
| Observer | Blend notifications | Core Services publish a "Blend ready" event; group members are notified without polling |
| Microservices | Spring Boot ↔ FastAPI | Decouples transactional CRUD from AI compute; services are independently deployable and scalable |
erDiagram
USER {
uuid id
string username
string email
}
WATCH_ENTRY {
uuid id
enum status
int rating
text review
}
MEDIA_ITEM {
uuid id
string tmdb_id
string title
text overview
string genres
string tags
vector embedding
}
OTT_AVAILABILITY {
uuid id
string platform
string deeplink
string region
}
WATCH_GROUP {
uuid id
string name
}
BLEND {
uuid id
json candidate_list
json scores
enum status
}
SOCIAL_EDGE {
uuid follower_id
uuid followee_id
}
USER ||--o{ WATCH_ENTRY : "owns"
WATCH_ENTRY }o--|| MEDIA_ITEM : "references"
MEDIA_ITEM ||--o{ OTT_AVAILABILITY : "available on"
USER }o--o{ WATCH_GROUP : "member of"
WATCH_GROUP ||--o{ BLEND : "produces"
USER ||--o{ SOCIAL_EDGE : "follows"
MediaItem and OTTAvailability are produced exclusively by the TMDB Adapter, ensuring the rest of the system never touches raw TMDB JSON. WatchEntry.status drives both the personalisation pipeline and Blend filtering.
sequenceDiagram
actor Client
participant Filter as JWT Filter
participant Auth as AuthController
participant DB as PostgreSQL
participant Ctrl as Resource Controller
Client->>Auth: POST /auth/login {username, password}
Auth->>DB: validate credentials
DB-->>Auth: user record + role
Auth-->>Client: signed JWT {user_id, role, expiry}
Client->>Filter: GET /api/... Authorization: Bearer <token>
Filter->>Filter: validate signature + expiry
Filter->>Ctrl: request + SecurityContext {user_id, role}
Ctrl->>Ctrl: @PreAuthorize check
Note over Ctrl: WatchEntry: owner_id == principal_id
Note over Ctrl: Blend: principal is WatchGroup member
Ctrl-->>Client: 200 OK / 403 Forbidden
| Control | Mechanism |
|---|---|
| Authentication | Signed, short-lived JWT |
| Authorisation | RBAC via Spring Security @PreAuthorize |
| Data ownership | Service-layer ownership check (owner_id == principal_id) |
| Transport | HTTPS for all inter-service and external communication |
| Secret management | JWT signing key and DB credentials via environment variables |
StreamSync is deployed as 8 independently containerised units: a React SPA, an API Gateway, five Spring Boot Java services (auth, user, movie, recommendation, interaction), and one Python FastAPI ML service (ssm-engine). Services communicate over HTTP REST; the user-service additionally publishes internal Spring events consumed asynchronously by RecommendationStateNotifier.
C4Container
title Implemented — Microservices Architecture
Person(u, "User")
System_Boundary(ss, "StreamSync") {
Container(spa, "React SPA", "Vite + React", "Browser UI")
Container(gw, "API Gateway", "Spring Cloud Gateway", "Route + CORS")
Container(auth, "auth-service", "Spring Boot", "JWT issuance")
Container(usr, "user-service", "Spring Boot", "Profiles, history, social")
Container(mov, "movie-service", "Spring Boot", "Catalog + search")
Container(rec, "recommendation-service","Spring Boot", "SSM state + Blend")
Container(ssm, "ssm-engine", "FastAPI + PyTorch", "ML inference, FAISS")
ContainerDb(mdb,"movie-db", "PostgreSQL", "Movie catalog")
ContainerDb(rdb,"recommendation-db", "PostgreSQL", "SSM states + embeddings")
ContainerDb(udb,"userdb", "H2 (file)", "User profiles")
ContainerDb(adb,"authdb", "H2 (file)", "Credentials")
}
Rel(u, spa, "HTTPS")
Rel(spa, gw, "HTTPS + Bearer JWT")
Rel(gw, auth, "/api/auth/**")
Rel(gw, usr, "/api/user/**")
Rel(gw, mov, "/api/movies/**")
Rel(gw, rec, "/api/recommendation/**")
Rel(mov, ssm, "REST — semantic search")
Rel(rec, ssm, "REST — SSM state / Blend")
Rel(usr, rec, "Async event — watched movie")
Rel(mov, mdb, "JDBC")
Rel(rec, rdb, "JDBC")
Rel(usr, udb, "JPA")
Rel(auth,adb, "JPA")
Key structural properties:
| Property | Value |
|---|---|
| Deployable units | 8 containers (+ 2 Postgres instances + 1 H2 each for auth/user) |
| Inter-service communication | HTTP REST (synchronous), Spring ApplicationEventPublisher (async in-process) |
| Technology boundary | Java (5 services) / Python (1 service) split enforced at the wire |
| Datastore strategy | Database-per-service (2 × Postgres, 2 × H2) |
| Java source (non-test) | ~3,300 lines across 46 files, 5 services |
| Python source | ~1,900 lines across 6 modules (app.py, ssm_model.py, build_index.py, adapters, strategies) |
| ML inference path hops | 3 HTTP hops: Browser → Gateway → movie-service → ssm-engine |
NFRs addressed: Modifiability, Deployability, Scalability, Independent Evolvability.
The system is broken into narrowly-scoped services — authentication, user profile management, the movie catalog, user interactions, recommendations, and an ML inference engine — all fronted by a single API Gateway that routes requests by path prefix.
Each service owns its own datastore (database-per-service) so that schema evolution in one domain never cascades into another. The ML inference service is entirely Python-based, isolated from the JVM services, so GPU dependencies, model updates, and Python library upgrades have no impact on the transactional Spring Boot core.
C4Container
title StreamSync — Planned Container Layout
Person(user, "Viewer / Friend Group")
System_Boundary(ss, "StreamSync") {
Container(spa, "React SPA", "Vite + React", "Single-page UI")
Container(gw, "API Gateway", "Spring Cloud Gateway", "Path-routed entrypoint; JWT validation")
Container(auth, "auth-service", "Spring Boot", "JWT issuance & credential management")
Container(usr, "user-service", "Spring Boot", "Profiles, follows, watchlist, watch history")
Container(mov, "movie-service", "Spring Boot", "Movie catalog reads & semantic search relay")
Container(rec, "recommendation-service", "Spring Boot", "SSM state management & Watch Party blends")
Container(int, "interaction-service", "Spring Boot", "Ratings and interaction events")
Container(ssm, "ssm-engine", "FastAPI + PyTorch + FAISS", "Embedding inference, ANN search, group aggregation")
ContainerDb(mdb, "movie-db", "Postgres", "Movie catalog")
ContainerDb(rdb, "recommendation-db", "Postgres", "User SSM states & embeddings")
ContainerDb(cache, "Redis", "Key-value store", "Blend cache & Pub/Sub (planned)")
}
Rel(user, spa, "Uses")
Rel(spa, gw, "HTTPS + Bearer JWT")
Rel(gw, auth, "/api/auth/**")
Rel(gw, usr, "/api/user/**")
Rel(gw, mov, "/api/movies/**")
Rel(gw, rec, "/api/recommendation/**")
Rel(mov, ssm, "REST — semantic search")
Rel(rec, ssm, "REST — SSM state update / search / group blend")
Rel(usr, rec, "Internal event — watched movie notification")
Rel(mov, mdb, "JDBC")
Rel(rec, rdb, "JDBC")
Rel(rec, cache, "Cache reads/writes (planned)")
Future extension: The gateway layer is the natural point to plug in rate-limiting, circuit breakers, and API versioning without touching individual services.
NFRs addressed: Availability, Fault Tolerance, Latency, Maintainability.
Every algorithm-heavy request path is decomposed into an ordered chain of strategies:
- Primary — the richer, ML-backed path (semantic vector search, Mamba SSM recommendations, group state aggregation).
- Fallback — a simpler, self-contained alternative that has no dependency on the ML service (keyword/SQL-based search, static genre-based recommendations, pre-cached blend results).
Each strategy signals availability explicitly — returning an empty result rather than throwing — so the fallback chain activates silently without exposing error state to the caller.
flowchart TD
A[Search Request] --> B{Semantic Search\nML Service available?}
B -- Yes, hits found --> C[Return ranked vector results]
B -- No / empty --> D{Keyword Search\nSQL LIKE fallback}
D -- Results found --> E[Return keyword results]
D -- Empty --> F[Return empty list]
Future extension: Wrapping each primary strategy call in a circuit breaker (e.g. Resilience4j) was planned as the production hardening step.
NFRs addressed: Interoperability, Correctness, Modifiability, Data Integrity.
An Adapter is placed at every integration boundary:
- TMDB Adapter: responsible for reading raw TMDB records in whatever format they arrive (CSV rows, API JSON) and producing clean, validated domain objects with sanitised numeric values.
- SSM Engine Adapter: responsible for translating between the Java domain model (typed DTOs with named fields) and the ML service's HTTP/JSON protocol (flat maps, serialised tensor lists).
flowchart LR
subgraph Java Domain
A[RecommendationService]
B[MovieCatalogService]
end
subgraph Adapters
C[SsmEngineAdapter\ntyped DTO ↔ HTTP/JSON]
D[TmdbAdapter\nraw CSV/API → domain objects]
end
subgraph External
E[ssm-engine\nFastAPI REST]
F[TMDB\nCSV / API]
end
A --> C --> E
B --> D --> F
Future extension: Formalising the adapter boundary with an OpenAPI schema for the ML service contract, and introducing schema versioning so the Java layer could negotiate which API version to use.
NFRs addressed: Responsiveness, Availability, Extensibility, Loose Coupling.
The event-driven decoupling works as follows:
- The user-service controller records the watch event and immediately returns a success response.
- An in-process event is published (
WatchedMovieAddedEvent) carrying the username and movie identifier. - A dedicated observer (
RecommendationStateNotifier) picks up the event asynchronously on a background thread and forwards it to the recommendation service. - Failure in the notification is non-fatal and logged as a warning — the user's action always succeeds.
sequenceDiagram
participant U as User (Browser)
participant UC as user-service
participant EQ as Event Bus (in-process)
participant RN as RecommendationStateNotifier
participant RS as recommendation-service
U->>UC: POST /watched {tmdb_id}
UC->>UC: Persist watch record
UC->>EQ: publish WatchedMovieAddedEvent
UC-->>U: 200 OK (immediate)
EQ-->>RN: onWatchedMovieAdded (async)
RN->>RS: POST /internal/ssm-state/watched
RS->>RS: Update Mamba SSM state
note over RN: Failure here is non-fatal
Future extension: Replacing the in-process event bus with a durable message broker (Kafka or RabbitMQ) for cross-service fan-out, guaranteed at-least-once delivery, and dead-letter queue handling.
NFRs addressed: Performance (< 300 ms latency), Scalability, Data Freshness, Reliability.
The expensive work is done before any user request arrives:
- At startup (or on a scheduled 24h refresh), the ML service downloads the latest TMDB dataset and builds a FAISS approximate-nearest-neighbour index from pre-computed embeddings.
- At query time, only the user's free-text query needs to be embedded — a single short string.
- The same in-memory index serves semantic search, Mamba SSM state-based recommendations, and Watch Party group blends.
Ingestion is implemented as a streaming pipeline: the ML service exposes NDJSON-streaming endpoints, and downstream Java services consume them in bounded chunks via line-by-line HTTP streaming, writing to their own databases in small batches. This caps memory usage regardless of catalog size.
sequenceDiagram
participant K as Kaggle (dataset source)
participant SSM as ssm-engine
participant MS as movie-service
participant RS as recommendation-service
SSM->>K: Download latest TMDB CSV
SSM->>SSM: Build FAISS index + generate embeddings
SSM->>SSM: /health → "ready"
MS->>SSM: GET /movie-database-stream (NDJSON chunks)
SSM-->>MS: stream chunk 1 … chunk N
MS->>MS: Upsert catalog rows (bounded JDBC batches)
RS->>SSM: Poll /build-status until stream_finished
RS->>SSM: GET /rebuild-and-embeddings-stream
SSM-->>RS: stream embedding vectors (NDJSON)
RS->>RS: Persist embeddings to recommendation-db
note over MS,RS: Both consumers are memory-bounded\nregardless of catalog size
StreamSync is decomposed into seven discrete subsystems, each containerised and independently deployable.
React SPA (5173)
│ HTTP REST
▼
API Gateway (8080)
│ routes to
├─► Auth Service
├─► User Service
├─► Movie Service ──────────────┐
| │ HTTP REST
└─► Recommendation Service ──────┤
▼
SSM Engine (5000)
│
┌───────────┴───────────┐
FAISS Index Kaggle/TMDB CSV
(ssm-data volume)
Technology: Java Spring Boot | Exposed Port: 8080
Single entry point for all inbound client traffic. It routes requests to the appropriate downstream microservice, acting as a simple reverse-proxy/router that keeps the client decoupled from the internal service topology. All cross-cutting concerns (rate limiting, request logging) are centralised here.
Technology: Java Spring Boot
Endpoints: POST /api/auth/register, POST /api/auth/login
Handles user registration and password-based authentication. On successful login it issues HMAC-SHA256 signed JWTs that are validated locally by downstream services without any further round-trip to this service. The stateless token design means the auth service is only hit at login/register time, keeping it lightweight and independently scalable.
Technology: Java Spring Boot + JPA
Endpoints: GET|PUT /{username}/settings, POST|DELETE /{username}/watchlist/{id}, POST|GET|PUT|DELETE /{username}/watched-movies, POST /{username}/follow/{friend}, GET /{username}/followers
The central user state microservice. It owns the UserProfile document which stores the watchlist, full watched-movie history (with timestamps and ratings), and social relationships (UserRelationship). Every POST /watched-movies triggers a fire-and-forget async call to the Recommendation Service's internal SSM state update endpoint, creating the event-driven bridge that keeps the ML model in sync with user activity without blocking the CRUD response.
Technology: Java Spring Boot + PostgreSQL (movie-db)
Endpoints: GET /api/movies/{id}, POST /api/movies/batch, GET /api/movies/popular, GET /api/movies/top-rated, GET /api/movies/search, POST /api/movies/semantic-search
Manages the TMDB movie catalogue persisted in Postgres. On startup (or scheduled refresh every 24h), MovieCatalogBootstrapLoader streams the full Kaggle dataset from the ssm-engine as NDJSON and bulk-inserts it via JDBC batch updates. For semantic search, MovieController acts as an Adapter: it forwards the query to SsmEngineClient.semanticSearch(), receives FAISS hit IDs, enriches them with full MovieDto metadata from Postgres (preserving score order), and falls back to a SQL LIKE search if the ML engine is unavailable.
Technology: Java Spring Boot + PostgreSQL (recommendation-db)
Endpoints: GET /api/recommendation/users/{username}/movie-ids, POST /api/recommendation/watch-party, POST /internal/ssm-state/watched
The broker between the Java backend and the Python ML engine. It has two roles:
-
SSM State Lifecycle (
UserSsmStateService): When notified of a new watch event, it retrieves the user's last serialised SSM state from Postgres, POSTs it with the newtmdb_idto/update_stateon the ssm-engine, and UPSERTs the returned new state back touser_ssm_state. For recommendation retrieval it POSTs the stored state to/search-by-state. -
Watch Party Orchestration (
WatchPartyService): Reads persisted SSM states for all listed participants, builds a JSON payload with all states and exclusion lists, and POSTs to/combine-stateson the ssm-engine. Returns the resolvedmovie_idslist plus metadata about which users had available states.
Technology: Python FastAPI + PyTorch (S4D SSM) + FAISS + SentenceTransformers
Exposed Port: 5000 | GPU: Optional NVIDIA GPU
The core ML microservice — implements every computation-intensive operation:
| Endpoint | Purpose |
|---|---|
POST /update_state |
Runs one SSM step() forward pass with a movie embedding; returns updated serialised state |
POST /search |
Rebuilds full SSM state from an ordered ID list; FAISS ANN search + quality re-rank |
POST /search-by-state |
Deserialises stored state, applies query fusion (α × E_newest + (1−α) × S_ssm), FAISS ANN + re-rank |
POST /semantic-search |
Encodes a free-text query with all-MiniLM-L6-v2; FAISS ANN + re-rank |
POST /combine-states |
Watch Party: per-user individual FAISS retrieval → Least Misery (min similarity) post-retrieval re-ranking |
GET /movie-database-stream |
Streams the Kaggle TMDB CSV as chunked NDJSON |
GET /rebuild-and-embeddings-stream |
Triggers a clean FAISS index rebuild then streams all embeddings |
GET /health, GET /build-status |
Operational status and indexing progress |
The SSM model (ssm_model.py) is a MultiScaleS4D: a stack of S4DBlock layers each containing an S4DKernel (ZOH-discretised diagonal state-space model with complex-valued A). The readout uses recency-weighted pooling blended with a deterministic fallback for stability without a trained checkpoint.
The FAISS index (build_index.py) uses IndexFlatIP on L2-normalised all-MiniLM-L6-v2 embeddings of rich text documents (Title · Genres · Director · Cast · Tagline · Synopsis · Decade). GPU-adaptive batch embedding with automatic CUDA OOM recovery ensures robust builds on constrained hardware.
Technology: React.js + Vite + Tailwind CSS | Exposed Port: 5173
Single-Page Application providing the end-user interaction layer. It is structured around eight page-level modules:
| Page | Purpose |
|---|---|
auth |
Login / registration forms |
home |
Landing / discovery feed |
moviePage |
Single-movie detail view |
profile |
User profile, watch history, social stats |
accountPage |
Account settings |
recommendations |
Personalised SSM recommendation list |
searchPage |
Semantic "vibe" search interface |
socialPage |
Friends, followers, Watch Party Blend launcher |
Shared application state (auth token, current user) is managed via React Context (context/). API calls to the backend are centralised in the services/ layer. The app is hot-reload capable in development via Docker volume bind-mounts (./frontend/src:/app/src).
StreamSync's movie search must serve a natural-language "vibe" query against a corpus of hundreds of thousands of titles. The ideal path is semantic vector search via the ssm-engine ML service. However, the ML service is an external dependency with its own startup time, GPU requirements, and failure modes. Hard-coding the call to ssm-engine would propagate any ML service unavailability directly to the user as an error.
The same tension exists in Watch Party group scoring: the product wants to experiment with different aggregation methods (least-misery, average, multiplicative) without restructuring the endpoint each time.
The Strategy pattern solves both problems by defining a common algorithm interface and allowing concrete implementations to be swapped at configuration time (search) or at call time (group aggregation) — without changing any calling code.
| Role | Class | Package |
|---|---|---|
| Strategy interface | MovieSearchStrategy |
movie_service.search |
| Concrete Strategy A | SemanticSearchStrategy |
movie_service.search |
| Concrete Strategy B | KeywordSearchStrategy |
movie_service.search |
| Composite Strategy | FallbackSearchStrategy |
movie_service.search |
| Context | MovieController |
movie_service |
| Composer / wiring | MovieSearchConfig |
movie_service.search |
| Role | Class | Module |
|---|---|---|
| Strategy interface | GroupAggregator (Protocol) |
strategies/group_aggregator.py |
| Concrete Strategy A | LeastMiseryAggregator |
strategies/group_aggregator.py |
| Concrete Strategy B | AverageAggregator |
strategies/group_aggregator.py |
| Concrete Strategy C | MultiplicativeAggregator |
strategies/group_aggregator.py |
| Context | combine_states endpoint |
app.py |
| Registry | AGGREGATORS dict |
strategies/group_aggregator.py |
classDiagram
class MovieSearchStrategy {
<<interface>>
+search(query, topK, minVoteCount) Optional~List~MovieDto~~
}
class SemanticSearchStrategy {
-SsmEngineClient ssmEngineClient
-MovieQueryService movieQueryService
+search(query, topK, minVoteCount) Optional~List~MovieDto~~
}
class KeywordSearchStrategy {
-MovieQueryService movieQueryService
+search(query, topK, minVoteCount) Optional~List~MovieDto~~
}
class FallbackSearchStrategy {
-MovieSearchStrategy primary
-MovieSearchStrategy fallback
+search(query, topK, minVoteCount) Optional~List~MovieDto~~
}
class MovieController {
-MovieSearchStrategy movieSearchStrategy
+semanticSearch(request) List~MovieDto~
}
class MovieSearchConfig {
+movieSearchStrategy(semantic, keyword) MovieSearchStrategy
}
MovieSearchStrategy <|.. SemanticSearchStrategy : implements
MovieSearchStrategy <|.. KeywordSearchStrategy : implements
MovieSearchStrategy <|.. FallbackSearchStrategy : implements
FallbackSearchStrategy o-- MovieSearchStrategy : primary
FallbackSearchStrategy o-- MovieSearchStrategy : fallback
MovieController --> MovieSearchStrategy : uses
MovieSearchConfig ..> FallbackSearchStrategy : creates
MovieSearchConfig ..> SemanticSearchStrategy : injects as primary
MovieSearchConfig ..> KeywordSearchStrategy : injects as fallback
MovieSearchConfig is the only class that knows the concrete strategy types. MovieController is injected with only the MovieSearchStrategy interface and never references any concrete class.
flowchart TD
A([POST /api/movies/semantic-search\nquery, topK, minVoteCount]) --> B[MovieController\n.semanticSearch]
B --> C[FallbackSearchStrategy\n.search]
C --> D[SemanticSearchStrategy\n.search — primary]
D --> E{ssm-engine available\nand returned hits?}
E -- Yes --> F([Return ranked\nvector results])
E -- No / empty --> G[log: activating fallback]
G --> H[KeywordSearchStrategy\n.search — fallback]
H --> I{SQL LIKE matches?}
I -- Yes --> J([Return keyword\nresults])
I -- No --> K([Return empty list])
Each search() implementation returns Optional.empty() to signal "I have no answer — try the next strategy", rather than throwing an exception. FallbackSearchStrategy.search() inspects the Optional and delegates to its fallback strategy if empty. The controller receives a single Optional<List<MovieDto>> and never knows which strategy ran.
The same pattern governs Watch Party scoring in Python. The GroupAggregator protocol defines a single aggregate(sim) method that receives a (num_candidates × num_participants) similarity matrix and returns a (num_candidates,) group-score vector.
classDiagram
class GroupAggregator {
<<Protocol>>
+aggregate(sim ndarray) ndarray
}
class LeastMiseryAggregator {
+aggregate(sim) ndarray
}
class AverageAggregator {
+aggregate(sim) ndarray
}
class MultiplicativeAggregator {
+aggregate(sim) ndarray
}
GroupAggregator <|.. LeastMiseryAggregator
GroupAggregator <|.. AverageAggregator
GroupAggregator <|.. MultiplicativeAggregator
The combine_states endpoint resolves the concrete aggregator from the AGGREGATORS registry using the string key supplied in the request body ("least_misery", "average", or "product"). A new scoring method requires only a new class and a single registry entry — zero changes to the endpoint logic.
The Strategy pattern enforces the proposal's core reliability guarantee: "Swap algorithms at runtime: Mamba → a simpler Blend Filter fallback if ML service is unavailable." Without it, the only option for handling ML unavailability would be a try/catch scattered through the controller. With the pattern, the controller is permanently closed to modification: a new strategy is a new class and a config change.
Recording a watched movie — POST /api/user/{username}/watched-movies — is a latency-sensitive user action that must return immediately. However, it triggers a downstream side effect that is both slow and non-essential: the user's Mamba SSM state in recommendation-service must be updated. If this update were made synchronously, every "mark as watched" request would block on a 10-second HTTP round-trip to recommendation-service.
The Observer pattern solves this by separating what happened (the user watched a movie) from what should happen as a consequence (update the SSM state, audit-log the event, trigger notifications). The subject publishes an event and returns immediately; observers handle consequences independently, asynchronously, and without the subject knowing they exist.
| GoF Role | Class | Location |
|---|---|---|
| Subject / Publisher | UserProfileController |
user_service.controller |
| Event | WatchedMovieAddedEvent |
user_service.event |
| Event Bus | ApplicationEventPublisher |
Spring Framework |
| Concrete Observer | RecommendationStateNotifier |
user_service.event |
| Target (notified system) | UserSsmStateService |
recommendation_service |
classDiagram
class ApplicationEventPublisher {
<<Spring interface>>
+publishEvent(event ApplicationEvent)
}
class UserProfileController {
-ApplicationEventPublisher eventPublisher
+addWatchedMovie(username, body, request) ResponseEntity
}
class WatchedMovieAddedEvent {
-String username
-String tmdbId
+getUsername() String
+getTmdbId() String
}
class RecommendationStateNotifier {
-String recommendationServiceBaseUrl
-HttpClient httpClient
+onWatchedMovieAdded(event WatchedMovieAddedEvent)
}
class UserSsmStateService {
+updateSsmState(username, tmdbId)
}
UserProfileController --> ApplicationEventPublisher : publishEvent()
ApplicationEventPublisher ..> WatchedMovieAddedEvent : carries
RecommendationStateNotifier ..> WatchedMovieAddedEvent : listens via @EventListener
RecommendationStateNotifier --> UserSsmStateService : POST /internal/ssm-state/watched
UserProfileController depends only on ApplicationEventPublisher — it has no compile-time reference to RecommendationStateNotifier or any other observer. New observers (audit logging, analytics, push notifications) are independent @Component beans with @EventListener methods; adding one requires zero changes to the controller.
sequenceDiagram
actor User as Browser / User
participant UC as UserProfileController
participant DB as userdb (H2)
participant EP as ApplicationEventPublisher
participant RN as RecommendationStateNotifier
participant RS as recommendation-service
User->>UC: POST /api/user/{username}/watched-movies\n{ tmdbId, rating }
UC->>UC: validate ownership (JWT check)
UC->>DB: upsert WatchedMovie record
UC->>EP: publishEvent(WatchedMovieAddedEvent)
UC-->>User: 200 OK ← immediate return
Note over EP,RN: @Async — runs on background thread pool
EP-->>RN: onWatchedMovieAdded(event)
RN->>RS: POST /internal/ssm-state/watched\n{ username, tmdb_id }
RS->>RS: call ssm-engine /update_state\npersist new SSM state to recommendation-db
RS-->>RN: 200 OK
Note over RN: Failure here is non-fatal — logged as warning
The 200 OK reaches the user before RecommendationStateNotifier begins its HTTP call. The @Async annotation on onWatchedMovieAdded ensures the listener runs on a separate thread from Spring's async executor. If recommendation-service is down, the warning is logged and swallowed — the user's watch record is already committed safely in userdb.
sequenceDiagram
participant EP as ApplicationEventPublisher
participant RN as RecommendationStateNotifier
participant AN as BlendNotifier (future)
participant AL as AuditLogger (future)
EP-->>RN: onWatchedMovieAdded (async)
EP-->>AN: onWatchedMovieAdded (async)
EP-->>AL: onWatchedMovieAdded (async)
All three fire concurrently on separate async threads from the same event. The controller and WatchedMovieAddedEvent are unchanged.
Without the Observer, the controller would either block (adding 3–10 seconds to the most frequent user write action) or ignore the ML state update entirely (causing stale recommendations). The Observer gives both: the user gets an immediate acknowledgement, and the SSM state is updated in the background. It also enforces the open/closed principle for side effects: any future consequence of "a user watched a movie" is a new @EventListener bean — not a new line in the controller.
StreamSync is deployed as 8 independently containerised units. Services communicate over HTTP REST; the user-service additionally publishes internal Spring events consumed asynchronously by RecommendationStateNotifier.
A Modular Monolith is a single deployable unit structured into internal domain modules with enforced package boundaries but no network communication between them. For StreamSync this would mean: a single Spring Boot application with Java packages for auth, user, movie, recommendation, and social; the Python ML inference replaced by either a JVM-compatible ML library (e.g., Deep Java Library / ONNX Runtime) or an embedded Python subprocess.
C4Container
title Alternative — Modular Monolith
Person(u, "User")
System_Boundary(ss, "StreamSync Monolith") {
Container(spa, "React SPA", "Vite + React", "Browser UI")
Container(mono, "StreamSync Server", "Spring Boot (single JAR)", "Auth · User · Movie · Recommendation · Social modules, co-hosted ML runtime (ONNX / DJL)")
ContainerDb(db, "Shared Database", "PostgreSQL", "All domain tables in one schema")
}
Rel(u, spa, "HTTPS")
Rel(spa, mono, "HTTPS + Bearer JWT")
Rel(mono, db, "JDBC / JPA")
| Property | Microservices | Modular Monolith |
|---|---|---|
| Deployable units | 8 containers | 1 JAR + 1 Postgres instance |
| Inter-service communication | HTTP REST + Spring events (async) | In-process method calls (0 ms overhead) |
| Technology boundary | Java / Python split enforced at the wire | Java only; ML constrained to JVM-compatible options |
| Datastore strategy | Database-per-service (2 × Postgres, 2 × H2) | Shared schema |
| Java source (non-test) | ~3,300 lines across 46 files | ~5,200 lines (same domain logic, no HTTP client boilerplate) |
| ML inference path hops | 3 HTTP hops | 0 network hops |
Offline pre-computation (the structural enabler)
The expensive work is done before any user request arrives:
build_index.pyencodes all ~1M TMDB titles usingsentence-transformers/all-MiniLM-L6-v2(embedding dimension D = 384) into a FAISSIndexFlatIP. This rebuild takes ~30 minutes on an RTX 2060.- At query time, only the user's short natural-language string is encoded — a single forward pass through a 6-layer, 22M-parameter model.
- Retrieval is an exact inner-product scan: O(N × D) = O(900,000 × 384) ≈ 345M multiply-accumulate operations, executed in <5 ms on CPU with AVX2 SIMD.
Per-request latency decomposition (microservices)
| Step | Operation | Estimated Cost |
|---|---|---|
| 1 | Browser → Gateway (TLS + routing) | ~2 ms |
| 2 | Gateway → movie-service (LAN HTTP) |
~1 ms |
| 3 | movie-service → ssm-engine (LAN HTTP) |
~1 ms |
| 4 | ssm-engine: query embedding (all-MiniLM-L6-v2) |
~15–40 ms |
| 5 | ssm-engine: FAISS IndexFlatIP.search(topK × 5) on 1M vectors |
~3–8 ms |
| 6 | ssm-engine: quality re-ranking (5× topK candidates in memory) |
<1 ms |
| 7 | ssm-engine → movie-service (LAN HTTP, JSON payload) |
~2 ms |
| 8 | movie-service: Postgres IN (tmdb_ids) lookup for enrichment |
~5–15 ms |
| 9 | movie-service → Gateway → Browser |
~2 ms |
| Total | ~32–72 ms |
Quantified comparison
| Metric | Microservices | Modular Monolith | Delta |
|---|---|---|---|
| Inter-service network overhead | ~8 ms | ~0 ms | +8 ms (microservices) |
| Query embedding (CPU) | ~15–40 ms | ~15–40 ms | 0 ms (identical model) |
| FAISS lookup (1M, D=384) | ~3–8 ms | ~3–8 ms | 0 ms |
| Total typical latency (CPU) | ~32–72 ms | ~28–68 ms | +4–8 ms (microservices) |
| Headroom vs 300 ms budget | >228 ms | >232 ms | −4 ms (microservices) |
| Latency when ML path is down | ~5–15 ms (SQL fallback) | ~5–15 ms (SQL fallback) | 0 ms |
Conclusion for NFR-1: Both architectures meet the < 300 ms target comfortably. The microservices architecture pays an ~8 ms network overhead tax for separating the ML and Java runtimes. This is architecturally acceptable because the headroom is large (>220 ms) and the overhead is bounded and predictable on a LAN.
Each service has its own build context in docker-compose.yml. A code change to recommendation-service triggers a rebuild and restart of only that container:
docker compose up --build recommendation-service
| Metric | Microservices | Modular Monolith |
|---|---|---|
| Independently deployable units | 8 | 1 |
| Average service size (Java) | ~660 lines / service | ~3,300 lines (whole app) |
| Rebuild scope after a logic change | 1 service container | Entire JAR |
| Independent GPU allocation | Yes — ssm-engine only reserves GPU |
No — GPU must be provisioned for the single process |
| Startup health-gate dependency | ssm-engine health check: up to 10 min (FAISS rebuild); other services unaffected |
Entire application unavailable until all modules including ML are ready |
| Scaling ML workloads independently | Add ssm-engine replicas behind a load balancer; Java services untouched |
Must scale the entire monolith to add ML capacity |
| Technology diversity | Java (Spring Boot) / Python (FastAPI + PyTorch + FAISS) | Java only |
ssm-engine in docker-compose.yml includes:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]This means GPU resources are allocated only to the ML container. The Python/CUDA ecosystem (torch, faiss-gpu, sentence-transformers) is the path of least resistance for production ML inference, and the microservices architecture is what makes this language boundary clean.
Microservices cost: A cold start of the full system can take up to 10 minutes while ssm-engine rebuilds the FAISS index over 1M titles. There are also 8 distinct process logs to monitor, 4 datastores to back up, and inter-service HTTP failures to diagnose — none of which exist in a monolith.
Modular monolith advantage: A single log stream, a single health endpoint, a single database backup, and in-process stack traces that cross module boundaries. Development velocity on a small team is meaningfully higher in the first weeks.
Microservices advantage: A crash in ssm-engine (Python OOM during FAISS rebuild, CUDA error) does not crash the Java services. movie-service falls back to KeywordSearchStrategy transparently. user-service continues recording watch history; the async RecommendationStateNotifier logs a warning and moves on.
Modular monolith cost: A Python OOM, a memory leak in the ML module, or a deadlock in the recommendation logic crashes or hangs the entire process. Auth, tracking, and social features go down alongside the ML failure.
In StreamSync's context this trade-off is especially acute: the FAISS build is a 30-minute, memory-intensive operation that runs on a scheduled 24h cycle. Running it in a shared process with the CRUD workload risks OOM-killing the entire application mid-day.
Modular monolith advantage: In-process module calls mean no HTTP client code, no serialisation contracts, no depends_on health-check tuning, no container networking to debug. The Java services in StreamSync contain meaningful HTTP client boilerplate (SsmEngineClient, SsmEngineAdapter, NdjsonStreamProcessor, SsmBuildStatusPoller) — roughly ~300 lines of code that exists solely to manage inter-service communication.
Microservices cost: The Python/Java language boundary required explicit typed adapter classes and a streaming NDJSON protocol to move large datasets between services at startup.
Microservices cost: When a user marks a movie as watched, three writes happen across service boundaries:
user-service→userdb: persistWatchedMovie(synchronous, in the request).user-service→recommendation-service→recommendation-db: update SSM state (asynchronous, fire-and-forget).movie-service→movie-db: no write, but catalog must reflect the title (populated at startup).
Steps 1 and 2 are not in a distributed transaction. This is an accepted eventual-consistency trade-off: the user's watch record is always durable; the ML recommendation quality lags by at most one watch event.
Modular monolith advantage: All writes occur in a single ACID transaction. The SSM state update and the watch record commit atomically; no eventual-consistency lag is possible.
| Quality Attribute | Microservices | Modular Monolith | Winner |
|---|---|---|---|
| ML inference latency (NFR-1) | ~32–72 ms (+8 ms network) | ~28–68 ms | Monolith (marginal) |
| Independent scalability (NFR-2) | Strong — GPU isolated to ML containers | Weak — must scale entire app | Microservices |
| Fault isolation | Strong — ML crash ≠ CRUD crash | Weak — shared process | Microservices |
| Development velocity (4 weeks) | Moderate — HTTP boilerplate cost | High — in-process calls | Monolith |
| Operational simplicity | Low — 8 containers, 4 datastores | High — 1 process, 1 DB | Monolith |
| Data consistency | Eventual (async SSM update) | Strong ACID | Monolith |
| Technology flexibility | High — Java + Python + FAISS | Low — JVM only | Microservices |
| Cold-start time | Long (FAISS build gates services) | Long (but single wait) | Draw |
| ML model update with zero CRUD downtime | Yes — restart ssm-engine only |
No — full restart | Microservices |
The microservices architecture wins on the attributes that matter most for StreamSync's long-term viability: independent GPU allocation, fault isolation between ML and CRUD, and the ability to iterate on the ML model without touching any Java service. The monolith wins on short-term development simplicity, operational overhead, and data consistency — all meaningful costs for a 4-person, 4-week project, but not architectural dealbreakers given the explicit mandate for independent deployability in the project proposal.
The following describes individual contributions towards the Prototype Development phase of the project.
| Member | Roll Number | Contributions |
|---|---|---|
| Evan Bijoy | 2023101080 | Frontend, basic integration |
| Abhiram Tilak | 2022113011 | Foundational architecture, user DB, authentication |
| Adithya Kishor | 2023111019 | Python sidecar, ML, SSM, recommendation service |
| Anirudh Sankar | 2023111024 | User service, frontend integration, documentation |
GitHub Repository: https://github.com/serc-project/stream-sync