- What is this system?
- Project Structure
- Parent POM & Dependency Management
- common — Shared Interfaces & DTOs
- ui-service — The Public API Gateway
- manager-service — The Brain
- worker — The Compute Pod
- cli — The Command-Line Tool
- examples/wordcount — Sample Job
- External Infrastructure
- Authentication Flows
- File Upload Flow
- Full Job Lifecycle
- Failure Handling & Retries
- Dockerfiles
- Kubernetes Setup
- Docker Compose (local dev)
- Database Schema
- Monitoring & Health
- Complete End-to-End Flow
Ok so at a high level this is a cloud-native distributed MapReduce platform. The classic example people use for MapReduce is word count — you have a huge text file and want to count how many times each word appears. Instead of doing that on one machine (slow for big files), you split the work across many machines. That's MapReduce: Map phase splits the work, Reduce phase combines the results.
The interesting part of this project is how it's all implemented:
- It's not Hadoop. It doesn't use HDFS. It uses MinIO (self-hosted S3) for storage.
- Workers aren't long-lived servers. Each map/reduce task is its own Kubernetes batch Job — a pod that starts, does its thing, and exits.
- Users bring their own code. They upload a JAR file that implements the
MapperandReducerinterfaces. The system loads it at runtime via Java's ServiceLoader mechanism. - Auth is done with Keycloak — real OAuth2/OIDC, real JWTs, real JWKS validation.
- The user interacts via a CLI tool (
mr) that has an interactive shell mode.
The whole thing runs in Kubernetes. The manager service dynamically creates worker pods via the Fabric8 Kubernetes client. When a job finishes, you get back presigned MinIO URLs to download the results.
This is a Maven multi-module project. The root pom.xml declares all the modules and manages shared dependency versions via a BOM-style <dependencyManagement> block.
distributed/
│
├── pom.xml ← parent POM, manages all versions
│
├── common/ ← shared library (NOT a runnable service)
│ └── src/main/java/gr/tuc/distributed/common/
│ ├── api/
│ │ ├── Mapper.java ← interface users implement
│ │ └── Reducer.java ← interface users implement
│ ├── dto/
│ │ ├── JobSubmitRequest.java
│ │ ├── JobStatusResponse.java
│ │ ├── TaskStatusUpdate.java
│ │ └── FileUploadResponse.java
│ └── enums/
│ ├── JobStatus.java
│ ├── TaskStatus.java
│ └── TaskType.java
│
├── ui-service/ ← public REST API, auth, forwards to manager
│ ├── Dockerfile
│ └── src/main/java/gr/tuc/distributed/ui/
│ ├── UiApplication.java
│ ├── config/
│ │ ├── SecurityConfig.java
│ │ ├── WebClientConfig.java
│ │ └── OpenApiConfig.java
│ ├── controller/
│ │ ├── AuthController.java
│ │ ├── JobController.java
│ │ ├── DataController.java
│ │ ├── CodeController.java
│ │ └── AdminController.java
│ └── client/
│ ├── ManagerClient.java
│ └── KeycloakAdminClient.java
│
├── manager-service/ ← orchestration, K8s, DB, MinIO
│ ├── Dockerfile
│ └── src/main/java/gr/tuc/distributed/manager/
│ ├── ManagerApplication.java
│ ├── config/
│ │ ├── SecurityConfig.java
│ │ ├── MinioConfig.java
│ │ └── KubernetesConfig.java
│ ├── controller/
│ │ ├── InternalJobController.java
│ │ ├── InternalTaskController.java
│ │ └── InternalFileController.java
│ ├── service/
│ │ ├── JobOrchestrationService.java
│ │ └── FileService.java
│ ├── k8s/
│ │ └── KubernetesJobLauncher.java
│ ├── minio/
│ │ ├── MinioStorageService.java
│ │ └── MinioOperationException.java
│ ├── entity/
│ │ ├── Job.java
│ │ ├── Task.java
│ │ └── FileMetadata.java
│ ├── repository/
│ │ ├── JobRepository.java
│ │ ├── TaskRepository.java
│ │ └── FileMetadataRepository.java
│ └── scheduler/
│ └── HeartbeatWatchdog.java
│
├── worker/ ← stateless batch pod
│ ├── Dockerfile
│ └── src/main/java/gr/tuc/distributed/worker/
│ ├── WorkerApplication.java
│ ├── runner/
│ │ └── WorkerRunner.java
│ ├── minio/
│ │ └── WorkerMinioClient.java
│ └── reporter/
│ └── ManagerReporter.java
│
├── cli/ ← fat JAR CLI tool
│ ├── mr ← shell launcher script
│ └── src/main/java/gr/tuc/distributed/cli/
│ ├── Main.java
│ ├── MapReduceCli.java
│ ├── api/
│ │ ├── ApiClient.java
│ │ └── ApiException.java
│ ├── command/
│ │ ├── LoginCommand.java
│ │ ├── LogoutCommand.java
│ │ ├── RegisterCommand.java
│ │ ├── WhoamiCommand.java
│ │ ├── UploadCommand.java
│ │ ├── SubmitCommand.java
│ │ ├── RunCommand.java
│ │ ├── JobsCommand.java
│ │ ├── StatusCommand.java
│ │ ├── CancelCommand.java
│ │ └── ResultsCommand.java
│ ├── config/
│ │ ├── Session.java
│ │ └── SessionStore.java
│ └── util/
│ ├── Out.java
│ └── MultipartBodyPublisher.java
│
├── examples/
│ └── wordcount/ ← sample MapReduce job
│ └── src/main/java/gr/tuc/distributed/examples/wordcount/
│ ├── WordCountMapper.java
│ └── WordCountReducer.java
│
├── k8s/ ← all Kubernetes manifests
│ ├── namespace/namespace.yaml
│ ├── postgres/
│ ├── minio/
│ ├── keycloak/
│ ├── manager/
│ └── ui/
│
└── docker-compose.yml ← local dev setup
File: pom.xml (root)
- groupId:
gr.tuc.distributed - artifactId:
mapreduce-parent - version:
1.0.0-SNAPSHOT - packaging:
pom
The parent POM's main job is version management so you don't repeat versions in every child module. Key things it controls:
<properties>
<java.version>21</java.version>
<spring-boot.version>3.3.4</spring-boot.version>
<fabric8.version>6.13.1</fabric8.version>
<minio.version>8.5.11</minio.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<lombok.version>1.18.34</lombok.version>
<flyway.version>10.15.0</flyway.version>
<testcontainers.version>1.20.1</testcontainers.version>
</properties>The <dependencyManagement> section imports the Spring Boot BOM and the Spring Cloud BOM, plus pins Fabric8, MinIO, Flyway, MapStruct, and TestContainers. This means child POMs just say <artifactId>kubernetes-client</artifactId> without a version number — it comes from here.
Build plugins managed at the parent level:
spring-boot-maven-plugin— for packaging runnable JARsmaven-compiler-plugin— with annotation processors for Lombok + MapStruct (they run at compile time)
This module is a library — it produces a JAR that the other modules depend on. It never runs standalone.
Why does it exist? Because manager-service, worker, and cli all need to agree on what a JobStatus looks like, what TaskStatusUpdate contains, what Mapper.map() signature is, etc. Keeping these in a shared module means you only define them once.
// gr.tuc.distributed.common.api.Mapper
public interface Mapper {
void map(String inputKey, String line, BiConsumer<String, String> emit);
}
// gr.tuc.distributed.common.api.Reducer
public interface Reducer {
void reduce(String key, List<String> values, BiConsumer<String, String> emit);
}The emit parameter is a BiConsumer<String, String> — it's basically a callback. When your mapper finds a word, instead of returning a list, you just call emit.accept("word", "1"). This is cleaner than returning a List<Map.Entry<>> because the framework can decide how to buffer/flush without the user needing to manage collections.
These interfaces are loaded at runtime via ServiceLoader (explained in detail in the worker section).
// JobStatus.java
public enum JobStatus {
INITIALIZING, // job created, not started yet
MAP_PHASE, // map workers running
REDUCE_PHASE, // reduce workers running
COMPLETED, // all done, results available
FAILED, // something went wrong permanently
CANCELLED // user cancelled it
}
// TaskStatus.java
public enum TaskStatus {
IDLE, // task created, worker not launched yet
IN_PROGRESS, // worker pod is running
COMPLETED, // worker finished successfully
FAILED // worker failed, may be retried
}
// TaskType.java
public enum TaskType {
MAP,
REDUCE
}These are the objects that get serialized to/from JSON when services talk to each other or when the CLI talks to the API.
JobSubmitRequest — what you send when submitting a job:
@Data
public class JobSubmitRequest {
@NotBlank
private String dataId; // UUID of uploaded data file
@NotBlank
private String codeId; // UUID of uploaded code JAR
@Min(1)
private int numReducers; // how many reduce workers to use
}JobStatusResponse — what you get back when polling a job:
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class JobStatusResponse {
private UUID jobId;
private JobStatus status;
private List<String> outputUrls; // presigned download URLs (only when COMPLETED)
private String errorMessage; // only set if FAILED
private Instant createdAt;
private Instant updatedAt;
}TaskStatusUpdate — what a worker sends to the manager when its status changes:
@Data
public class TaskStatusUpdate {
@NotNull
private TaskStatus status; // IN_PROGRESS, COMPLETED, or FAILED
private String outputLocation; // MinIO path to output (set when COMPLETED)
private String errorMessage; // error description (set when FAILED)
}FileUploadResponse — what you get back after uploading a file:
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class FileUploadResponse {
private String id; // UUID of the file_metadata record
private String storagePath; // MinIO object key
}Dependencies for common: Lombok (for @Data, @Builder, etc.), Jackson annotations (for JSON), Jakarta Validation API (for @NotBlank, @Min).
Port: 8080 (exposed as NodePort 30080 in Kubernetes)
Role: This is the only service the outside world ever talks to. It handles authentication (login, register), then proxies everything else to the manager service. It also validates JWT tokens on every protected endpoint.
spring-boot-starter-web → REST server
spring-boot-starter-security → security filter chain
spring-boot-starter-oauth2-resource-server → JWT validation against Keycloak JWKS
spring-boot-starter-validation → @Valid on request bodies
spring-boot-starter-actuator → /actuator/health for K8s probes
micrometer-registry-prometheus → metrics at /actuator/prometheus
springdoc-openapi-starter-webmvc-ui → Swagger UI at /swagger-ui.html
lombok → @Data, @RequiredArgsConstructor, etc.
common (internal) → shared DTOs
server:
port: 8080
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI} # e.g. http://keycloak:8080/realms/mapreduce
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI} # same base + /protocol/openid-connect/certs
servlet:
multipart:
max-file-size: 1GB
max-request-size: 1GB
manager:
base-url: ${MANAGER_BASE_URL:http://manager-service:8081}
keycloak:
realm: mapreduce
admin:
base-url: ${KEYCLOAK_ADMIN_BASE_URL}
username: ${KEYCLOAK_ADMIN_USERNAME}
password: ${KEYCLOAK_ADMIN_PASSWORD}@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
// public endpoints
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
// admin-only
.requestMatchers("/api/v1/admin/**").hasRole("admin")
// everything else needs a valid JWT
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(keycloakJwtConverter())
)
)
.build();
}The keycloakJwtConverter() bean is important — Keycloak puts roles inside realm_access.roles in the JWT, but Spring Security expects ROLE_ prefixed authorities. The converter reads realm_access.roles and maps each one to ROLE_<rolename>.
This is the only controller that doesn't require a JWT to call.
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final KeycloakAdminClient keycloakAdminClient;
// Login: exchange username+password for a JWT
@PostMapping("/token")
public ResponseEntity<?> login(@RequestBody LoginRequest body) { ... }
// Check if a username is already taken before registering
@GetMapping("/check-username")
public ResponseEntity<Map<String, Boolean>> checkUsername(@RequestParam String username) { ... }
// Create a new account
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest body) { ... }
@Data static class LoginRequest { String username; String password; }
@Data static class RegisterRequest { String username; String password; }
}The login method calls Keycloak's token endpoint directly:
POST http://keycloak:8080/realms/mapreduce/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password&client_id=mapreduce-cli&username=alice&password=secret123
Keycloak responds with a JSON body containing access_token, expires_in, token_type, etc. The UI service extracts those and returns them to the CLI.
@RestController
@RequestMapping("/api/v1/jobs")
@RequiredArgsConstructor
public class JobController {
private final ManagerClient managerClient;
@GetMapping
public List<JobStatusResponse> listJobs(HttpServletRequest request) { ... }
@PostMapping
public ResponseEntity<Map<String, String>> submitJob(
@Valid @RequestBody JobSubmitRequest body,
HttpServletRequest request) { ... }
@GetMapping("/{jobId}")
public JobStatusResponse getJobStatus(
@PathVariable UUID jobId,
HttpServletRequest request) { ... }
@DeleteMapping("/{jobId}")
public ResponseEntity<Void> cancelJob(
@PathVariable UUID jobId,
HttpServletRequest request) { ... }
}Every method extracts the Authorization header from the incoming HttpServletRequest and forwards it to ManagerClient. The manager service then validates the same JWT again on its end.
Both are simple proxies — they receive a multipart/form-data POST with a file, forward it to the manager service with the same auth header, and return the FileUploadResponse.
Requires ROLE_admin. Exposes:
POST /api/v1/admin/users— create a user (calls Keycloak admin API)DELETE /api/v1/admin/users/{userId}— delete a user
@Data
static class CreateUserRequest {
@NotBlank String username;
@NotBlank String email;
@NotBlank String password;
}This is how the UI service talks to the manager service. It's built on Spring's RestClient (new in Spring 6, replaces RestTemplate).
@Component
public class ManagerClient {
private final RestClient managerClient; // injected via @Qualifier
public FileUploadResponse uploadData(MultipartFile file, String authHeader) {
// POST /internal/files/data (multipart/form-data)
}
public FileUploadResponse uploadCode(MultipartFile file, String authHeader) {
// POST /internal/files/code (multipart/form-data)
}
public List<JobStatusResponse> listJobs(String authHeader) {
// GET /internal/jobs
}
public String submitJob(JobSubmitRequest request, String authHeader) {
// POST /internal/jobs → returns jobId string
}
public JobStatusResponse getJobStatus(UUID jobId, String authHeader) {
// GET /internal/jobs/{jobId}
}
public void cancelJob(UUID jobId, String authHeader) {
// DELETE /internal/jobs/{jobId}
}
}The RestClient is configured in WebClientConfig with the manager base URL from application.yml, so the client methods just specify the path.
This is how the UI service creates/manages Keycloak users. It uses the Keycloak Admin REST API (not a dedicated SDK).
@Component
@Slf4j
public class KeycloakAdminClient {
@Value("${keycloak.admin.base-url}") private String baseUrl;
@Value("${keycloak.admin.username}") private String adminUsername;
@Value("${keycloak.admin.password}") private String adminPassword;
@Value("${keycloak.realm}") private String realm;
// Gets a short-lived admin token from the master realm
private String adminToken() {
// POST http://keycloak:8080/realms/master/protocol/openid-connect/token
// grant_type=password, client_id=admin-cli, username=admin, password=...
}
// Returns true if the username is NOT taken
public boolean isUsernameAvailable(String username) {
// GET /admin/realms/{realm}/users?username={username}&exact=true
// returns false if response list is non-empty
}
// Creates the user and sets their password
public void createUser(String username, String password) {
// POST /admin/realms/{realm}/users
// { "username": "...", "enabled": true, "credentials": [...] }
// throws UsernameConflictException if 409
// throws RegistrationException for other errors
}
}Important: this client logs in to the master realm with admin credentials to get an admin token, then uses that token to manage users in the mapreduce realm. The admin credentials come from env vars.
Port: 8081 (ClusterIP — not exposed externally, only reachable from inside the cluster)
Role: This service does the heavy lifting. It owns the database, talks to Kubernetes, reads/writes MinIO, and orchestrates the entire MapReduce job lifecycle.
spring-boot-starter-web → internal REST API
spring-boot-starter-security → validate JWT on requests from UI
spring-boot-starter-oauth2-resource-server → JWT decoding via Keycloak JWKS
spring-boot-starter-data-jpa → ORM with Hibernate
postgresql → JDBC driver
flyway-core + flyway-database-postgresql → SQL migrations on startup
spring-boot-starter-validation → @Valid on request bodies
spring-boot-starter-actuator → health probes
micrometer-registry-prometheus → metrics
io.fabric8:kubernetes-client (6.13.1) → create/delete K8s Jobs at runtime
io.minio:minio (8.5.11) → read/write MinIO object storage
lombok → code generation
mapstruct → entity ↔ DTO mapping
common (internal) → shared DTOs and enums
server:
port: 8081
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mapreduce}
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # Flyway manages schema, Hibernate just validates
show-sql: false
flyway:
enabled: true
locations: classpath:db/migration
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI}
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI}
servlet:
multipart:
max-file-size: 1GB
minio:
endpoint: ${MINIO_ENDPOINT:http://minio:9000}
public-endpoint: ${MINIO_PUBLIC_ENDPOINT:http://localhost:9000} # for presigned URLs
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
bucket: ${MINIO_BUCKET:mapreduce}
k8s:
namespace: ${K8S_NAMESPACE:mapreduce}
worker:
image: ${WORKER_IMAGE:mapreduce/worker:latest}
backoff-limit: ${K8S_BACKOFF_LIMIT:3}
app:
manager:
internal-url: ${MANAGER_INTERNAL_URL:http://manager-service:8081}
mapreduce:
default-map-tasks: ${DEFAULT_MAP_TASKS:4}
watchdog:
interval-ms: 15000Two MinIO endpoint configs exist because:
minio.endpointis used for actual data operations (internal cluster DNS, fast)minio.public-endpointis used when generating presigned URLs that the CLI/browser will download from (has to be a URL the client can reach)
The manager has a two-tier security config using Spring Security's @Order on filter chains:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Chain 1 (priority order=1): worker callbacks and health checks → no auth
@Bean @Order(1)
public SecurityFilterChain workerCallbackChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/internal/tasks/**", "/actuator/health/**", "/actuator/prometheus")
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.build();
}
// Chain 2 (lower priority): everything else → JWT required
@Bean @Order(2)
public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(keycloakJwtConverter())
))
.build();
}
}Why do workers get a free pass? Because worker pods are created by the manager dynamically — they don't have a JWT. They only know the manager URL and their task ID. The internal task endpoints don't need JWT because they're on a ClusterIP service (not reachable from outside the cluster) and the worker already needs to know the task ID to call them.
Entities are JPA-managed objects that map to database tables.
@Entity @Table(name = "jobs")
@Getter @Setter @NoArgsConstructor
public class Job {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID jobId;
@Column(name = "user_id", nullable = false)
private String userId; // Keycloak subject claim (user UUID)
@Enumerated(EnumType.STRING)
private JobStatus status; // stored as VARCHAR "MAP_PHASE" etc.
private String codePath; // MinIO key: users/{userId}/code/{uuid}_name.jar
private String inputPath; // MinIO prefix: users/{userId}/raw/
private String outputPath; // MinIO prefix: users/{userId}/results/{jobId}/
private int numMapTasks;
private int numReduceTasks;
private String errorMessage;
@CreationTimestamp
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
@OneToMany(mappedBy = "job", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Task> tasks = new ArrayList<>();
}@Entity @Table(name = "tasks")
@Getter @Setter @NoArgsConstructor
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID taskId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "job_id")
private Job job;
@Enumerated(EnumType.STRING)
private TaskType taskType; // MAP or REDUCE
@Enumerated(EnumType.STRING)
private TaskStatus status; // IDLE, IN_PROGRESS, COMPLETED, FAILED
private String workerPodId; // K8s Job name: worker-map-{taskId}
private String inputSplit; // comma-separated MinIO object keys
private String outputLocation; // MinIO path where worker wrote output
private int retryCount; // incremented on each failure, max 3
private Instant lastHeartbeat; // updated every 10s by the worker
private String errorMessage;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
}@Entity @Table(name = "file_metadata")
@Getter @Setter @NoArgsConstructor
public class FileMetadata {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID fileId;
private String userId; // Keycloak sub
private String fileType; // "DATA" or "CODE"
private String originalName; // original filename from upload
private String storagePath; // MinIO object key
private Long sizeBytes;
@CreationTimestamp
private Instant createdAt;
}Spring Data JPA repositories — just interfaces, Spring generates the implementation.
public interface JobRepository extends JpaRepository<Job, UUID> {
List<Job> findByUserId(String userId);
List<Job> findByStatus(JobStatus status);
Optional<Job> findByJobIdAndUserId(UUID jobId, String userId);
}
public interface TaskRepository extends JpaRepository<Task, UUID> {
List<Task> findByJobJobId(UUID jobId);
List<Task> findByJobJobIdAndTaskType(UUID jobId, TaskType type);
List<Task> findByJobJobIdAndStatus(UUID jobId, TaskStatus status);
List<Task> findByStatusAndLastHeartbeatBefore(TaskStatus status, Instant threshold);
long countByJobJobIdAndStatus(UUID jobId, TaskStatus status);
long countByJobJobIdAndTaskType(UUID jobId, TaskType type);
}
public interface FileMetadataRepository extends JpaRepository<FileMetadata, UUID> {
Optional<FileMetadata> findByFileIdAndUserId(UUID fileId, String userId);
List<FileMetadata> findByUserIdAndFileType(String userId, String fileType);
}The findByStatusAndLastHeartbeatBefore query in TaskRepository is specifically used by the heartbeat watchdog to find stuck tasks.
This is the brain of the whole system. Every job lifecycle event flows through here.
@Service @RequiredArgsConstructor @Slf4j
public class JobOrchestrationService {
private final JobRepository jobRepository;
private final TaskRepository taskRepository;
private final FileMetadataRepository fileMetadataRepository;
private final KubernetesJobLauncher k8sLauncher;
private final MinioStorageService minioService;
@Value("${mapreduce.default-map-tasks:4}")
private int defaultMapTasks;submitJob(JobSubmitRequest request, String userId) — called when a user submits a job:
- Looks up
FileMetadatafordataIdandcodeId(checks they belong to this user) - Creates a
Jobentity with statusINITIALIZING - Sets
outputPath=users/{userId}/results/{jobId}/ - Saves to DB
- Returns the
jobId - Asynchronously (via
@Asyncor just in a new thread) callsstartMapPhase(job)
startMapPhase(Job job):
- Lists all MinIO objects under
job.getInputPath()(the user's uploaded data files) - Splits the list into
numMapTaskschunks (round-robin assignment) - For each chunk:
- Creates a
Taskentity (taskType=MAP,status=IDLE,inputSplit=key1,key2,...) - Saves to DB
- Creates a
- Updates job status to
MAP_PHASE - For each task, calls
k8sLauncher.launchWorker(...)and stores the returned K8s job name asworkerPodId - Updates all tasks from
IDLEtoIN_PROGRESS(well, the workers will also confirm this themselves)
handleTaskUpdate(UUID taskId, TaskStatusUpdate update) — called when a worker reports back:
@Transactional
public void handleTaskUpdate(UUID taskId, TaskStatusUpdate update) {
Task task = taskRepository.findById(taskId).orElseThrow();
Job job = task.getJob();
switch (update.getStatus()) {
case IN_PROGRESS -> {
task.setStatus(TaskStatus.IN_PROGRESS);
task.setLastHeartbeat(Instant.now());
}
case COMPLETED -> {
task.setStatus(TaskStatus.COMPLETED);
task.setOutputLocation(update.getOutputLocation());
onTaskCompleted(job, task);
}
case FAILED -> {
task.setStatus(TaskStatus.FAILED);
task.setErrorMessage(update.getErrorMessage());
onTaskFailed(job, task);
}
}
taskRepository.save(task);
}onTaskCompleted(Job job, Task completedTask):
- Counts how many tasks of the current type are completed
- If this is a MAP task and ALL maps are now done → calls
startReducePhase(job) - If this is a REDUCE task and ALL reduces are done → sets job to
COMPLETED, generates output URLs
startReducePhase(Job job):
- Collects
outputLocationfrom all completed MAP tasks - Splits those MinIO paths across
numReduceTasksreducers (round-robin) - Creates
Taskentities (taskType=REDUCE,inputSplit= paths from map outputs) - Updates job status to
REDUCE_PHASE - Launches a K8s worker Job for each reduce task
onTaskFailed(Job job, Task failedTask):
private void onTaskFailed(Job job, Task failedTask) {
if (failedTask.getRetryCount() < 3) {
failedTask.setRetryCount(failedTask.getRetryCount() + 1);
failedTask.setStatus(TaskStatus.IDLE);
// delete old K8s job (if it still exists)
if (failedTask.getWorkerPodId() != null) {
k8sLauncher.deleteJob(failedTask.getWorkerPodId());
}
// launch a new worker for the same task
String newPodId = k8sLauncher.launchWorker(
failedTask.getTaskId(), job.getJobId(),
failedTask.getTaskType().name(),
failedTask.getInputSplit(), job.getCodePath()
);
failedTask.setWorkerPodId(newPodId);
} else {
// no more retries → job fails permanently
job.setStatus(JobStatus.FAILED);
job.setErrorMessage("Task " + failedTask.getTaskId() + " failed after 3 retries");
jobRepository.save(job);
}
}cancelJob(UUID jobId, String userId):
- Looks up job (checks ownership)
- Sets status to
CANCELLED - For each task that is not completed, deletes the corresponding K8s Job via
k8sLauncher.deleteJob(task.getWorkerPodId())
getJobStatus(UUID jobId, String userId):
When the job is COMPLETED, this generates presigned download URLs:
- Lists all MinIO objects under
job.getOutputPath()(the results/ prefix) - For each object key, calls
minioService.presignedGetUrl(key, 3600)(1 hour expiry) - Returns
JobStatusResponsewith those URLs inoutputUrls
@Service @RequiredArgsConstructor @Slf4j
public class FileService {
private final MinioStorageService minioService;
private final FileMetadataRepository fileMetadataRepository;
@Transactional
public FileUploadResponse uploadData(MultipartFile file, String userId) {
String key = "users/" + userId + "/raw/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
return upload(file, userId, "DATA", key);
}
@Transactional
public FileUploadResponse uploadCode(MultipartFile file, String userId) {
String key = "users/" + userId + "/code/" + UUID.randomUUID() + "_" + file.getOriginalFilename();
return upload(file, userId, "CODE", key);
}
private FileUploadResponse upload(MultipartFile file, String userId, String type, String key) {
minioService.upload(key, file.getInputStream(), file.getSize(), file.getContentType());
FileMetadata meta = new FileMetadata();
meta.setUserId(userId);
meta.setFileType(type);
meta.setOriginalName(file.getOriginalFilename());
meta.setStoragePath(key);
meta.setSizeBytes(file.getSize());
fileMetadataRepository.save(meta);
return FileUploadResponse.builder()
.id(meta.getFileId().toString())
.storagePath(key)
.build();
}
}This is how the manager creates worker pods. It uses the Fabric8 Kubernetes client (Java SDK for the Kubernetes API).
@Service @RequiredArgsConstructor @Slf4j
public class KubernetesJobLauncher {
private final KubernetesClient kubernetesClient;
@Value("${k8s.namespace:mapreduce}") private String namespace;
@Value("${k8s.worker.image}") private String workerImage;
@Value("${k8s.worker.backoff-limit:3}") private int backoffLimit;
@Value("${app.manager.internal-url}") private String managerUrl;
@Value("${minio.endpoint}") private String minioEndpoint;
@Value("${minio.access-key}") private String minioAccessKey;
@Value("${minio.secret-key}") private String minioSecretKey;
@Value("${minio.bucket}") private String minioBucket;launchWorker(UUID taskId, UUID jobId, String taskType, String inputSplit, String codePath):
This method builds a full batch/v1/Job spec using Fabric8's fluent builder API:
Job k8sJob = new JobBuilder()
.withNewMetadata()
.withName("worker-" + taskType.toLowerCase() + "-" + taskId)
.withNamespace(namespace)
.addToLabels("app", "mapreduce-worker")
.addToLabels("job-id", jobId.toString())
.addToLabels("task-id", taskId.toString())
.addToLabels("task-type", taskType)
.endMetadata()
.withNewSpec()
.withBackoffLimit(backoffLimit) // K8s-level retry limit
.withNewTemplate()
.withNewSpec()
.withRestartPolicy("Never") // don't restart the pod, K8s creates new one
.addNewContainer()
.withName("worker")
.withImage(workerImage)
.withEnv(buildEnvVars(taskId, jobId, taskType, inputSplit, codePath))
.withNewResources()
.addToRequests("cpu", new Quantity("250m"))
.addToRequests("memory", new Quantity("256Mi"))
.addToLimits("cpu", new Quantity("1"))
.addToLimits("memory", new Quantity("512Mi"))
.endResources()
.endContainer()
.endSpec()
.endTemplate()
.endSpec()
.build();
kubernetesClient.batch().v1().jobs()
.inNamespace(namespace)
.resource(k8sJob)
.create();The buildEnvVars(...) method creates a list of EnvVar objects for all the environment variables the worker needs:
TASK_ID → taskId.toString()
JOB_ID → jobId.toString()
TASK_TYPE → "MAP" or "REDUCE"
INPUT_SPLIT → "key1,key2,key3"
CODE_PATH → "users/{userId}/code/{uuid}_code.jar"
MANAGER_URL → "http://manager-service:8081"
MINIO_ENDPOINT → "http://minio:9000"
MINIO_ACCESS_KEY → from manager's config
MINIO_SECRET_KEY → from manager's config
MINIO_BUCKET → "mapreduce"
deleteJob(String k8sJobName):
public void deleteJob(String k8sJobName) {
kubernetesClient.batch().v1().jobs()
.inNamespace(namespace)
.withName(k8sJobName)
.withPropagationPolicy(DeletionPropagation.BACKGROUND) // also deletes pods
.delete();
}@Service @Slf4j
public class MinioStorageService {
private final MinioClient minioClient; // internal endpoint (@Primary)
private final MinioClient publicMinioClient; // public endpoint (@Qualifier)
@Value("${minio.bucket}")
private String bucket;
public String upload(String objectKey, InputStream data, long size, String contentType) {
ensureBucketExists();
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket).object(objectKey)
.stream(data, size, -1)
.contentType(contentType)
.build());
return objectKey;
}
public InputStream download(String objectKey) {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket).object(objectKey).build());
}
public List<String> listObjects(String prefix) {
List<String> keys = new ArrayList<>();
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder().bucket(bucket).prefix(prefix).recursive(true).build()
);
for (Result<Item> result : results) {
keys.add(result.get().objectName());
}
return keys;
}
// Uses the PUBLIC endpoint so the presigned URL is accessible from outside the cluster
public String presignedGetUrl(String objectKey, int expirySeconds) {
return publicMinioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.bucket(bucket).object(objectKey)
.method(Method.GET)
.expiry(expirySeconds, TimeUnit.SECONDS)
.build());
}
}The reason there are two MinioClient beans (MinioConfig.java): when the manager is running inside the cluster, it accesses MinIO via http://minio:9000 (cluster DNS). But presigned URLs need to use the public address (like http://192.168.49.2:30900) because the CLI runs on the user's laptop — it can't resolve minio as a hostname.
@Component @RequiredArgsConstructor @Slf4j
public class HeartbeatWatchdog {
private final TaskRepository taskRepository;
private final JobOrchestrationService orchestrationService;
private static final long TIMEOUT_SECONDS = 30;
@Scheduled(fixedDelayString = "${mapreduce.watchdog.interval-ms:15000}")
public void checkDeadWorkers() {
Instant threshold = Instant.now().minusSeconds(TIMEOUT_SECONDS);
List<Task> deadTasks = taskRepository
.findByStatusAndLastHeartbeatBefore(TaskStatus.IN_PROGRESS, threshold);
for (Task task : deadTasks) {
log.warn("Task {} has no heartbeat since {} — marking as FAILED", task.getTaskId(), threshold);
TaskStatusUpdate update = new TaskStatusUpdate();
update.setStatus(TaskStatus.FAILED);
update.setErrorMessage("Heartbeat timeout — worker pod likely died");
orchestrationService.handleTaskUpdate(task.getTaskId(), update);
}
}
}This runs every 15 seconds. If a task is IN_PROGRESS but hasn't sent a heartbeat in 30 seconds, the watchdog marks it as FAILED. This triggers onTaskFailed which either retries the task or fails the whole job.
The @EnableScheduling annotation is on ManagerApplication — without it, @Scheduled annotations are ignored.
InternalJobController — /internal/jobs:
@GetMapping → listJobs(@AuthenticationPrincipal Jwt jwt)
@PostMapping → submitJob(@Valid @RequestBody, Jwt jwt)
@GetMapping("/{jobId}") → getJobStatus(@PathVariable UUID, Jwt jwt)
@DeleteMapping("/{jobId}") → cancelJob(@PathVariable UUID, Jwt jwt)The @AuthenticationPrincipal Jwt jwt parameter gets the decoded JWT. The user's ID is extracted as jwt.getSubject() (the sub claim), which is Keycloak's UUID for that user. This becomes userId in all DB operations.
InternalTaskController — /internal/tasks (no auth required):
@PostMapping("/{taskId}/status") → updateTaskStatus(@PathVariable UUID, @Valid body)
@PostMapping("/{taskId}/heartbeat") → heartbeat(@PathVariable UUID)InternalFileController — /internal/files:
@PostMapping("/data") → uploadData(@RequestParam("file") MultipartFile, Jwt jwt)
@PostMapping("/code") → uploadCode(@RequestParam("file") MultipartFile, Jwt jwt)The worker is fundamentally different from the other services. It's a Spring Boot CommandLineRunner — meaning it starts up, runs one task, and exits. It's not a server that accepts requests. It communicates by calling the manager's REST API.
@SpringBootApplication
@RequiredArgsConstructor
@Slf4j
public class WorkerApplication implements CommandLineRunner {
private final WorkerRunner workerRunner;
public static void main(String[] args) {
SpringApplication.run(WorkerApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
workerRunner.execute();
}
}The application.yml disables a bunch of Spring Boot auto-configuration that would try to connect to a database or set up security — the worker doesn't need any of that:
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration
- org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
server:
port: 0 # no HTTP server needed — worker only makes outbound calls
worker:
task-id: ${TASK_ID}
job-id: ${JOB_ID}
task-type: ${TASK_TYPE}
input-split: ${INPUT_SPLIT}
code-path: ${CODE_PATH}
manager-url: ${MANAGER_URL}
minio:
endpoint: ${MINIO_ENDPOINT}
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
bucket: ${MINIO_BUCKET}All the ${ENV_VAR} values come from the Kubernetes Job spec built by KubernetesJobLauncher.
@Component @RequiredArgsConstructor @Slf4j
public class WorkerRunner {
private final WorkerMinioClient minioClient;
private final ManagerReporter reporter;
@Value("${worker.task-type}") private String taskType;
@Value("${worker.task-id}") private String taskId;
@Value("${worker.job-id}") private String jobId;
@Value("${worker.input-split}") private String inputSplit; // comma-separated
@Value("${worker.code-path}") private String codePath;
@Value("${minio.bucket}") private String bucket;
private static final long HEARTBEAT_INTERVAL_MS = 10_000;execute() — the main method:
public void execute() {
// 1. Tell the manager we're starting
reporter.reportInProgress();
// 2. Start sending heartbeats in background thread
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor();
heartbeat.scheduleAtFixedRate(
reporter::sendHeartbeat,
HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, TimeUnit.MILLISECONDS
);
try {
// 3. Create a temp directory for working files
Path workDir = Files.createTempDirectory("worker-" + taskId);
// 4. Download the user's code JAR from MinIO to /tmp/worker-{taskId}/code.jar
Path jarPath = workDir.resolve("code.jar");
minioClient.downloadToFile(codePath, jarPath.toString());
// 5. Run the appropriate phase
String outputLocation;
if ("MAP".equals(taskType)) {
outputLocation = runMap(workDir, jarPath);
} else {
outputLocation = runReduce(workDir, jarPath);
}
// 6. Report success
reporter.reportCompleted(outputLocation);
} catch (Exception e) {
log.error("Task {} failed: {}", taskId, e.getMessage(), e);
reporter.reportFailed(e.getMessage());
} finally {
heartbeat.shutdownNow();
}
}runMap(Path workDir, Path jarPath) — step by step:
private String runMap(Path workDir, Path jarPath) throws Exception {
// 1. Load the user's Mapper class from their JAR using ServiceLoader
URLClassLoader classLoader = new URLClassLoader(
new URL[]{jarPath.toUri().toURL()},
Thread.currentThread().getContextClassLoader()
);
ServiceLoader<Mapper> loader = ServiceLoader.load(Mapper.class, classLoader);
Mapper mapper = loader.findFirst()
.orElseThrow(() -> new RuntimeException("No Mapper implementation found in JAR"));
// 2. Process each input file in the split
TreeMap<String, List<String>> intermediate = new TreeMap<>();
for (String objectKey : inputSplit.split(",")) {
String content = minioClient.downloadAsString(objectKey.trim());
String[] lines = content.split("\n");
for (int i = 0; i < lines.length; i++) {
// key is "filename:lineNumber", value is the line text
mapper.map(objectKey + ":" + i, lines[i], (k, v) -> {
intermediate.computeIfAbsent(k, x -> new ArrayList<>()).add(v);
});
}
}
// 3. Write intermediate results: "key\tvalue" per line
// One output file: temp/{jobId}/map-{taskId}/part-0.txt
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, List<String>> entry : intermediate.entrySet()) {
for (String val : entry.getValue()) {
sb.append(entry.getKey()).append('\t').append(val).append('\n');
}
}
String outputKey = "temp/" + jobId + "/map-" + taskId + "/part-0.txt";
minioClient.uploadText(outputKey, sb.toString());
return outputKey; // returned as outputLocation in the status report
}runReduce(Path workDir, Path jarPath) — step by step:
private String runReduce(Path workDir, Path jarPath) throws Exception {
// 1. Load the user's Reducer from their JAR
URLClassLoader classLoader = new URLClassLoader(
new URL[]{jarPath.toUri().toURL()},
Thread.currentThread().getContextClassLoader()
);
ServiceLoader<Reducer> loader = ServiceLoader.load(Reducer.class, classLoader);
Reducer reducer = loader.findFirst()
.orElseThrow(() -> new RuntimeException("No Reducer implementation found in JAR"));
// 2. Download all assigned intermediate files and group by key
TreeMap<String, List<String>> grouped = new TreeMap<>();
for (String objectKey : inputSplit.split(",")) {
String content = minioClient.downloadAsString(objectKey.trim());
for (String line : content.split("\n")) {
if (line.isBlank()) continue;
int tab = line.indexOf('\t');
String key = line.substring(0, tab);
String val = line.substring(tab + 1);
grouped.computeIfAbsent(key, x -> new ArrayList<>()).add(val);
}
}
// 3. Run reduce for each key
StringBuilder result = new StringBuilder();
for (Map.Entry<String, List<String>> entry : grouped.entrySet()) {
reducer.reduce(entry.getKey(), entry.getValue(), (k, v) -> {
result.append(k).append('\t').append(v).append('\n');
});
}
// 4. Write final output
// Extract userId from codePath convention: users/{userId}/code/...
String userId = extractUserId();
String outputKey = "users/" + userId + "/results/" + jobId + "/reduce-" + taskId + ".txt";
minioClient.uploadText(outputKey, result.toString());
return outputKey;
}The ServiceLoader mechanism — how user code gets loaded:
Java's ServiceLoader is a standard way to discover implementations of an interface at runtime. For it to work, the user's JAR must contain a file at:
META-INF/services/gr.tuc.distributed.common.api.Mapper
...containing exactly one line: the fully-qualified class name of their Mapper implementation. Same for Reducer. When the worker calls ServiceLoader.load(Mapper.class, classLoader), Java finds that file in the JAR and instantiates the class.
The URLClassLoader is key here — it creates a class loader that knows about the user's JAR file so it can load classes from it.
@Component @Slf4j
public class WorkerMinioClient {
// builds MinioClient from @Value properties in constructor
@Value("${minio.endpoint}") private String endpoint;
@Value("${minio.access-key}") private String accessKey;
@Value("${minio.secret-key}") private String secretKey;
@Value("${minio.bucket}") private String bucket;
public String downloadAsString(String objectKey) { ... } // GetObject → UTF-8 string
public void downloadToFile(String objectKey, String path) { ... } // download to disk
public String uploadText(String objectKey, String content) { ... } // PutObject from string
public String uploadFile(String objectKey, String path, String ct) { ... } // upload from disk
}@Component @Slf4j
public class ManagerReporter {
@Value("${worker.manager-url}") private String managerUrl;
@Value("${worker.task-id}") private String taskId;
private final RestClient restClient; // configured with managerUrl base
public void reportInProgress() {
post(TaskStatusUpdate with status=IN_PROGRESS);
}
public void reportCompleted(String outputLocation) {
post(TaskStatusUpdate with status=COMPLETED, outputLocation=outputLocation);
}
public void reportFailed(String errorMessage) {
post(TaskStatusUpdate with status=FAILED, errorMessage=errorMessage);
}
public void sendHeartbeat() {
restClient.post()
.uri("/internal/tasks/" + taskId + "/heartbeat")
.retrieve().toBodilessEntity();
}
private void post(TaskStatusUpdate update) {
restClient.post()
.uri("/internal/tasks/" + taskId + "/status")
.contentType(MediaType.APPLICATION_JSON)
.body(update)
.retrieve().toBodilessEntity();
}
}The CLI is a fat JAR built with Maven Shade plugin. It's a plain Java 21 app — no Spring Boot, no DI framework, just picocli + Jackson.
public class Main {
public static void main(String[] args) {
if (args.length == 0) {
// No arguments → drop into interactive REPL shell
runShell();
} else {
// Arguments given → run that command and exit
int code = new CommandLine(new MapReduceCli()).execute(args);
System.exit(code);
}
}
private static void runShell() {
CommandLine cmd = new CommandLine(new MapReduceCli());
Console console = System.console();
Out.bold("MapReduce CLI — type 'help' or 'exit'");
Out.println("");
while (true) {
String line = console != null
? console.readLine("mr> ")
: new Scanner(System.in).nextLine();
if (line == null || line.trim().equalsIgnoreCase("exit")
|| line.trim().equalsIgnoreCase("quit")) {
break;
}
if (line.isBlank()) continue;
// tokenize the line respecting quoted strings
String[] tokens = tokenize(line.trim());
cmd.execute(tokens);
}
}
}The shell tokenizer handles quoted arguments — so mr run "my file.txt" code.jar works correctly even with spaces in filenames.
@Command(
name = "mr",
version = "1.0",
mixinStandardHelpOptions = true,
subcommands = {
LoginCommand.class,
LogoutCommand.class,
RegisterCommand.class,
WhoamiCommand.class,
UploadCommand.class,
SubmitCommand.class,
RunCommand.class,
JobsCommand.class,
StatusCommand.class,
CancelCommand.class,
ResultsCommand.class
}
)
public class MapReduceCli implements Callable<Integer> {
@Override
public Integer call() {
// no subcommand given → print help
new CommandLine(this).usage(System.out);
return 0;
}
}Session.java — the data stored in ~/.mr/session.json:
@JsonIgnoreProperties(ignoreUnknown = true)
public class Session {
public String baseUrl; // http://192.168.49.2:30080
public String token; // JWT access token (eyJ...)
public String username; // alice
public long expiresAt; // System.currentTimeMillis() + expires_in * 1000
public String lastDataId; // UUID of last uploaded data file
public String lastCodeId; // UUID of last uploaded code JAR
public boolean isExpired() {
return System.currentTimeMillis() > expiresAt;
}
public String remainingTime() {
long remaining = (expiresAt - System.currentTimeMillis()) / 1000;
return String.format("%02d:%02d", remaining / 60, remaining % 60);
}
}SessionStore.java — loads and saves the session file:
public class SessionStore {
private static final Path SESSION_PATH =
Path.of(System.getProperty("user.home"), ".mr", "session.json");
private static final ObjectMapper MAPPER = new ObjectMapper();
public static Optional<Session> load() { ... }
public static void save(Session s) { ... }
public static void clear() { ... }
// Used by commands that require login — throws instead of System.exit
// so the interactive shell doesn't die when you forget to log in
public static Session require() {
return load()
.filter(s -> !s.isExpired())
.orElseThrow(() -> new IllegalStateException(
"Not logged in. Run 'mr login' first."
));
}
}Pure Java, uses java.net.http.HttpClient (built into Java 11+). No third-party HTTP library needed.
public class ApiClient {
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// Response records (Java 16+ records)
public record LoginResponse(String access_token, long expires_in) {}
public record FileUploadResponse(String id, String fileId) {
public String resolvedId() { return id != null ? id : fileId; }
}
public record JobStatus(
String jobId, String status, String errorMessage,
List<String> outputUrls, String createdAt, String updatedAt
) {}The MultipartBodyPublisher utility builds the multipart body for file uploads:
// cli/util/MultipartBodyPublisher.java
// Builds application/x-www-form-urlencoded / multipart/form-data bodies
// for java.net.http.HttpRequest — the standard library doesn't have a built-in multipart builder@Command(name = "login", description = "Log in to the MapReduce cluster.")
public class LoginCommand implements Callable<Integer> {
@Option(names={"--url","-u"}, defaultValue="http://192.168.49.2:30080")
String url;
@Option(names={"--username","-n"}, description="Username (prompted if omitted)")
String username;
@Option(names={"--password","-p"}, description="Password (prompted securely if omitted)", interactive=true)
char[] password;
@Override
public Integer call() {
// prompts for username/password if not supplied via flags
// calls api.login() → gets JWT
// saves Session to ~/.mr/session.json with expiresAt
// prints "Logged in as alice (token expires in 04:59)"
}
}The interactive=true on the --password option means picocli will read it securely (no echo) from the console if not supplied on the command line.
@Command(name = "register")
public class RegisterCommand implements Callable<Integer> {
@Option(names={"--url","-u"}, defaultValue="http://192.168.49.2:30080")
String url;
@Override
public Integer call() {
// 1. Prompt for username (min 3 chars)
// 2. Check availability: api.isUsernameAvailable() → loop if taken
// 3. Prompt for password (min 6 chars) with confirmation
// 4. api.register()
// 5. Print "Account created. Use 'mr login' to log in."
}
}@Command(name = "upload", subcommands = {DataSubcommand.class, CodeSubcommand.class})
public class UploadCommand { }
@Command(name = "data")
static class DataSubcommand implements Callable<Integer> {
@Parameters(index="0", paramLabel="<file>") Path file;
@Override
public Integer call() {
Session s = SessionStore.require();
var response = new ApiClient().uploadData(s.baseUrl, s.token, file);
s.lastDataId = response.resolvedId();
SessionStore.save(s); // remember for mr submit
Out.ok("Uploaded: " + response.resolvedId());
return 0;
}
}Same pattern for CodeSubcommand with uploadCode and lastCodeId.
@Command(name = "run", description = "Upload data + code, submit job, watch status.")
public class RunCommand implements Callable<Integer> {
@Parameters(index="0", paramLabel="<data>") Path dataFile;
@Parameters(index="1", paramLabel="<code.jar>") Path codeFile;
@Option(names={"--reducers","-r"}, defaultValue="2")
int reducers;
@Option(names={"--watch","-w"}, defaultValue="5",
description="Poll interval in seconds (0 to skip watching)")
int watchInterval;
@Override
public Integer call() {
Session s = SessionStore.require();
var api = new ApiClient();
// upload data
Out.print("Uploading data... ");
var dataResp = api.uploadData(s.baseUrl, s.token, dataFile);
Out.ok(dataResp.resolvedId());
// upload code
Out.print("Uploading code... ");
var codeResp = api.uploadCode(s.baseUrl, s.token, codeFile);
Out.ok(codeResp.resolvedId());
// submit job
String jobId = api.submitJob(s.baseUrl, s.token,
dataResp.resolvedId(), codeResp.resolvedId(), reducers);
Out.ok("Job submitted: " + jobId);
// watch until done
if (watchInterval > 0) {
while (true) {
Thread.sleep(watchInterval * 1000L);
var status = api.getJobStatus(s.baseUrl, s.token, jobId);
Out.print("\rStatus: " + status.status() + " "); // \r overwrites line
if (isTerminal(status.status())) {
Out.println("");
break;
}
}
}
return 0;
}
}The \r (carriage return) trick makes the status update overwrite the same line in the terminal — like a progress indicator.
@Command(name = "results", description = "View or download job output.")
public class ResultsCommand implements Callable<Integer> {
@Parameters(index="0", paramLabel="<jobId>") String jobId;
@Option(names={"--out","-o"}, description="Save raw output to file instead of displaying")
Path outFile;
@Option(names={"--top","-n"}, defaultValue="0", description="Show only top N results")
int top;The display logic:
- Gets job status (must be COMPLETED)
- Downloads all presigned URLs and concatenates the content
- Parses each line as
key\tvalue - Tries
Long.parseLong(value)— if all values parse, treats as numeric - If numeric: sorts by count descending, shows table with proportional bar chart
--top Nlimits the display to the N highest-count entries--out fileskips all that and just writes raw content to disk
The bar chart is rendered with Unicode block characters via Out.bar(pct, 30) — generates a string of █ characters proportional to pct out of 100.
@Command(name = "status")
public class StatusCommand implements Callable<Integer> {
@Parameters(index="0", paramLabel="<jobId>") String jobId;
@Option(names={"--watch","-w"}, defaultValue="0",
description="Poll interval in seconds (0 = one-shot)")
int watchInterval;With --watch 5, it polls every 5 seconds and overwrites the status line until the job reaches a terminal state (COMPLETED, FAILED, or CANCELLED).
public class Out {
// ANSI color codes for terminal
private static final String RESET = "\u001B[0m";
private static final String GREEN = "\u001B[32m";
private static final String RED = "\u001B[31m";
private static final String YELLOW = "\u001B[33m";
private static final String BOLD = "\u001B[1m";
private static final String DIM = "\u001B[2m";
public static void ok(String msg) { System.out.println(GREEN + "✓ " + msg + RESET); }
public static void err(String msg) { System.err.println(RED + "✗ " + msg + RESET); }
public static void warn(String msg) { System.out.println(YELLOW+ "! " + msg + RESET); }
public static void bold(String msg) { System.out.println(BOLD + msg + RESET); }
public static void dim(String msg) { System.out.println(DIM + msg + RESET); }
public static void hr(int width) { System.out.println("─".repeat(width)); }
public static String bar(int pct, int width) {
int filled = (int) Math.round(pct * width / 100.0);
return "█".repeat(filled) + "░".repeat(width - filled);
}
}#!/bin/sh
DIR="$(cd "$(dirname "$0")" && pwd)"
JAR="$DIR/target/mr.jar"
if [ ! -f "$JAR" ]; then
echo "mr.jar not found — building first..."
cd "$DIR" && flatpak-spawn --host mvn package -q -DskipTests
fi
exec java -jar "$JAR" "$@"flatpak-spawn --host is needed because VS Code runs as a Flatpak here, which sandboxes the shell — it can't see the host's Maven installation directly. flatpak-spawn --host breaks out of the sandbox and runs the command on the host OS.
This is how you write a MapReduce job for this system. It's deliberately simple.
public class WordCountMapper implements Mapper {
private static final Pattern PUNCTUATION = Pattern.compile("[^a-zA-Z0-9']");
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
@Override
public void map(String inputKey, String line, BiConsumer<String, String> emit) {
if (line == null || line.isBlank()) return;
for (String token : WHITESPACE.split(line)) {
String word = PUNCTUATION.matcher(token.toLowerCase()).replaceAll("");
if (!word.isBlank()) {
emit.accept(word, "1"); // emit (word, "1") for each occurrence
}
}
}
}public class WordCountReducer implements Reducer {
@Override
public void reduce(String key, List<String> values, BiConsumer<String, String> emit) {
long count = values.stream().mapToLong(Long::parseLong).sum();
emit.accept(key, String.valueOf(count));
}
}ServiceLoader registration files:
# META-INF/services/gr.tuc.distributed.common.api.Mapper
gr.tuc.distributed.examples.wordcount.WordCountMapper
# META-INF/services/gr.tuc.distributed.common.api.Reducer
gr.tuc.distributed.examples.wordcount.WordCountReducer
The pom.xml for wordcount uses <scope>provided</scope> for the common dependency — meaning it's not bundled in the JAR. The worker already has common on its classpath, so there's no need to ship it twice. This keeps the JAR small.
- Image:
postgres:16-alpine - Purpose: Persistent state for jobs, tasks, and file metadata
- Who talks to it: Manager service only (via Spring Data JPA + Flyway)
- Database name:
mapreduce - Schema management: Flyway runs SQL migration files in order at startup. The naming convention matters:
V{number}__{description}.sql.V1runs beforeV2, etc. - Connection details come from env vars:
DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORD
The manager uses a HikariCP connection pool (Spring Boot's default) — Hibernate holds a pool of database connections so it doesn't open a new TCP connection for every query.
- Image:
quay.io/minio/minio:latest - What it is: An S3-compatible object store you run yourself
- Ports: 9000 (S3 API), 9001 (web console UI)
- Bucket:
mapreduce(auto-created byMinioStorageService.ensureBucketExists()on first upload) - Why MinIO instead of a real filesystem? Because multiple services need to access the same files. A local filesystem only works on one machine. MinIO gives you S3-compatible object storage that any service in the cluster can access via HTTP.
Storage path conventions:
users/{userId}/raw/{uuid}_{filename} ← uploaded input data
users/{userId}/code/{uuid}_{filename}.jar ← uploaded code JARs
temp/{jobId}/map-{taskId}/part-0.txt ← MAP output (intermediate)
users/{userId}/results/{jobId}/reduce-{n}.txt ← final REDUCE output
Presigned URLs: When you request results for a completed job, the manager generates presigned GET URLs for each output file. A presigned URL is a regular HTTPS URL with an embedded signature and expiry time — anyone who has it can download the file for up to 1 hour without needing credentials.
- Image:
quay.io/keycloak/keycloak:25.0 - Mode:
start-dev(no TLS, embedded H2 or PostgreSQL) - Realm:
mapreduce - Client:
mapreduce-cli(withDirect Access Grantsenabled — needed for username/password login)
Keycloak issues JWT access tokens. The token contains:
sub: user UUID (used asuserIdeverywhere in the system)preferred_username: human-readable usernamerealm_access.roles: list of roles (["default-roles-mapreduce", "offline_access"]for regular users,["admin"]for admins)exp: expiry timeiss: issuer URI (e.g.http://keycloak:8080/realms/mapreduce)
Both UI service and manager service validate tokens by fetching Keycloak's JWKS (JSON Web Key Set) endpoint at:
http://keycloak:8080/realms/mapreduce/protocol/openid-connect/certs
This endpoint returns the public keys. Spring Security caches these keys and uses them to verify the JWT signature without calling Keycloak on every request.
- What it provides: Container orchestration — scheduling pods, managing resources, handling restarts
- Namespace:
mapreduce— all resources for this system live here, isolated from other workloads - Worker pods: Created dynamically at runtime by the manager service via the Fabric8 K8s client
- Why K8s for workers? Because you need to run many workers in parallel, each with specific env vars, resource limits, and restart policies. K8s
batch/v1/Jobis exactly designed for this use case — a task that runs to completion and exits.
mr login --url http://host:30080
[User's machine] [UI Service :30080] [Keycloak :30180]
│ │ │
│ POST /api/auth/token │ │
│ {username, password} │ │
├────────────────────────►│ │
│ │ POST /realms/mapreduce
│ │ /openid-connect/token │
│ │ grant_type=password │
│ │ client_id=mapreduce-cli
│ │ username=alice │
│ │ password=secret123 │
│ ├──────────────────────►│
│ │ { │
│ │ access_token: eyJ...,│
│ │ expires_in: 300, │
│ │ token_type: Bearer │
│ │ } │
│ │◄──────────────────────┤
│ {token, expiresAt, │ │
│ username} │ │
│◄────────────────────────┤ │
│ │ │
[saves ~/.mr/session.json]
CLI sends: Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
UI Service receives request:
1. Spring Security intercepts it
2. Extracts the Bearer token
3. Calls NimbusJwtDecoder.decode(token):
a. Splits JWT into header.payload.signature
b. Gets public key from cached JWKS (fetches from Keycloak if not cached)
c. Verifies signature using RS256 (RSA + SHA-256)
d. Checks exp claim (token not expired)
e. Checks iss claim matches configured issuer-uri
4. If valid: creates Authentication object with sub, roles
5. Puts Authentication in SecurityContextHolder
6. Request proceeds to controller
UI Service then calls Manager:
→ Forwards the SAME Authorization: Bearer header
Manager receives request:
→ Does the same JWT validation (same Keycloak JWKS endpoint)
→ Extracts userId from jwt.getSubject()
mr register
[CLI] [UI Service] [Keycloak Admin API]
│ │ │
│ POST /api/auth/register │ │
│ {username, password} │ │
├──────────────────────────────►│ │
│ │ GET /admin/realms/ │
│ │ mapreduce/users? │
│ │ username=alice&exact=true│
│ │ (with admin token) │
│ ├─────────────────────────►│
│ │ [] (empty = available) │
│ │◄─────────────────────────┤
│ │ POST /admin/realms/ │
│ │ mapreduce/users │
│ │ {username, enabled:true,│
│ │ credentials:[{type: │
│ │ password, value:...}]} │
│ ├─────────────────────────►│
│ │ 201 Created │
│ │◄─────────────────────────┤
│ 200 OK │ │
│◄──────────────────────────────┤ │
Logout is client-side only — the CLI just deletes ~/.mr/session.json. JWTs are stateless (the server doesn't keep a session table), so there's nothing to revoke server-side. The token will naturally expire based on the exp claim. In a production system you'd implement token revocation via an allowlist/denylist, but for this system, expiry is good enough.
mr upload data mybook.txt
[CLI] [UI :30080] [Manager :8081] [MinIO :9000] [PostgreSQL]
│ │ │ │ │
│ POST /api/v1/ │ │ │ │
│ data │ │ │ │
│ multipart/form │ │ │ │
│ Auth: Bearer │ │ │ │
├───────────────►│ │ │ │
│ │ validates JWT │ │ │
│ │ extracts userId │ │ │
│ │ │ │ │
│ │ POST /internal/ │ │ │
│ │ files/data │ │ │
│ │ (same multipart) │ │ │
│ │ Auth: Bearer │ │ │
│ ├──────────────────►│ │ │
│ │ │ validates JWT │ │
│ │ │ │ │
│ │ │ key = users/ │ │
│ │ │ {userId}/raw/ │ │
│ │ │ {uuid}_mybook.txt│ │
│ │ │ │ │
│ │ │ PutObject │ │
│ │ ├─────────────────►│ │
│ │ │ 200 OK │ │
│ │ │◄─────────────────┤ │
│ │ │ │ │
│ │ │ INSERT file_meta │ │
│ │ │ {userId, "DATA", │ │
│ │ │ key, size} │ │
│ │ ├───────────────────────────────────►
│ │ │ {fileId: uuid} │ │
│ │ {id: uuid} │◄──────────────────────────────────┤
│ {id: uuid} │◄──────────────────┤ │ │
│◄───────────────┤ │ │ │
[CLI saves id as session.lastDataId]
mr submit (or mr run data.txt code.jar which does upload + submit)
[CLI] [UI Service] [Manager Service] [PostgreSQL]
│ │ │ │
│ POST /api/v1/ │ │ │
│ jobs │ │ │
│ {dataId,codeId,│ │ │
│ numReducers} │ │ │
├───────────────►│ │ │
│ ├──────────────────►│ │
│ │ │ looks up │
│ │ │ file_metadata │
│ │ │ for dataId │
│ │ ├─────────────────►│
│ │ │ │
│ │ │ INSERT jobs │
│ │ │ status=INITIALIZING
│ │ ├─────────────────►│
│ │ │ {jobId: uuid} │
│ {jobId: uuid} │◄──────────────────┤ │
│◄───────────────┤ │ │
│ │ │ │
│ │ [async: startMapPhase]
The job submission is non-blocking — the HTTP request returns immediately with the jobId, then the map phase starts asynchronously. This is important so the CLI isn't hanging waiting for all workers to finish.
[Manager async] [Kubernetes API] [Worker Pod - MAP]
│ │ │
│ listObjects(inputPath) │ │
│ → ["key1.txt","key2.txt",...] │
│ │ │
│ split into 4 chunks: │ │
│ chunk0: [key1.txt] │ │
│ chunk1: [key2.txt] │ │
│ chunk2: [key3.txt] │ │
│ chunk3: [key4.txt] │ │
│ │ │
│ INSERT tasks (MAP, IDLE) │ │
│ × 4 │ │
│ │ │
│ UPDATE job → MAP_PHASE │ │
│ │ │
│ for each task: │ │
│ create batch/v1/Job │ │
│ name=worker-map-{taskId}│ │
│ env: TASK_TYPE=MAP │ │
│ env: INPUT_SPLIT=key.txt│ │
│ env: CODE_PATH=users/.. │ │
├──────────────────────────►│ │
│ │ schedules pod │
│ │ pulls worker image │
│ ├───────────────────────►│
│ │ │
│ │ │ reportInProgress()
│◄──────────────────────────────────────────────────┤
│ UPDATE task → IN_PROGRESS │ │
│ │ │
│ │ │ startHeartbeat()
│ │ │ downloadToFile(codePath)
│ │ │ URLClassLoader(code.jar)
│ │ │ ServiceLoader.load(Mapper)
│ │ │
│ │ │ for each key in INPUT_SPLIT:
│ │ │ downloadAsString(key)
│ │ │ for each line:
│ │ │ mapper.map(key:i, line, emit)
│ │ │
│ │ │ write intermediate to:
│ │ │ temp/{jobId}/map-{taskId}/part-0.txt
│ │ │
│ │ │ reportCompleted(outputKey)
│◄──────────────────────────────────────────────────┤
│ UPDATE task → COMPLETED │ │
│ task.outputLocation = ... │ [pod exits 0]
│ │ │
│ countCompleted == 4? → startReducePhase()
[Manager] [Kubernetes] [Worker Pod - REDUCE]
│ │ │
│ collect outputLocations │ │
│ from all 4 MAP tasks: │ │
│ [temp/job/map-0/part-0.txt,│ │
│ temp/job/map-1/part-0.txt,│ │
│ temp/job/map-2/part-0.txt,│ │
│ temp/job/map-3/part-0.txt]│ │
│ │ │
│ split across 2 reducers: │ │
│ reducer0: [map-0, map-2] │ │
│ reducer1: [map-1, map-3] │ │
│ │ │
│ INSERT tasks (REDUCE, IDLE) │ │
│ × 2 │ │
│ │ │
│ UPDATE job → REDUCE_PHASE │ │
│ │ │
│ CREATE batch/v1/Job ×2 │ │
│ env: TASK_TYPE=REDUCE │ │
│ env: INPUT_SPLIT=temp/..., │ │
│ temp/... │ │
├─────────────────────────────►│ │
│ ├─────────────────────────►│
│ │ │
│ │ │ downloadAsString(map-0)
│ │ │ downloadAsString(map-2)
│ │ │
│ │ │ parse lines → group by key
│ │ │ TreeMap {
│ │ │ "the" → ["1","1","1","1",...],
│ │ │ "and" → ["1","1","1",...],
│ │ │ ...
│ │ │ }
│ │ │
│ │ │ ServiceLoader.load(Reducer)
│ │ │
│ │ │ for each key:
│ │ │ reducer.reduce(key, values, emit)
│ │ │ // WordCountReducer sums them: 892
│ │ │
│ │ │ write to:
│ │ │ users/{id}/results/{jobId}/reduce-0.txt
│ │ │
│ │ │ reportCompleted(outputKey)
│◄─────────────────────────────────────────────────────── │
│ UPDATE task → COMPLETED │ [pod exits 0]
│ │ │
│ countCompleted == 2 → UPDATE job → COMPLETED
│ generate presigned URLs for output files
CLI polls every 5 seconds while watching:
GET /api/v1/jobs/{jobId}
→ UI → Manager → getJobStatus()
→ if COMPLETED: listObjects(outputPath) → presignedGetUrl() ×N
→ returns { status: COMPLETED, outputUrls: ["http://minio:9000/...?X-Amz-Signature=..."] }
mr results {jobId}:
→ downloads each presigned URL directly (not through the API, straight from MinIO)
→ concatenates all output files
→ parses as "word\tcount" pairs
→ sorts by count descending
→ renders table:
Results for job abc-123-...
156 unique entries · 12,450 total
KEY COUNT FREQUENCY
──────────────────────────────────────────────────────
the 892 ██████████████████████████████░
and 654 █████████████████████░░░░░░░░░
a 421 █████████████░░░░░░░░░░░░░░░░░
to 398 ████████████░░░░░░░░░░░░░░░░░░
...
There are three layers of retry in this system:
Layer 1: Kubernetes backoffLimit
spec:
backoffLimit: 3 # K8s will retry the pod up to 3 times if it exits non-zeroIf the pod crashes without calling reportFailed() (e.g., OOMKilled, network error before any code runs), Kubernetes automatically restarts it. The restartPolicy: Never means don't restart the same pod — Kubernetes creates a new pod.
Layer 2: Application-level retry in Manager (onTaskFailed)
When a worker reports FAILED back to the manager:
retry_count = 0: delete old K8s job → launch new one → task stays IN_PROGRESS
retry_count = 1: same
retry_count = 2: same
retry_count = 3: give up → mark job as FAILED, set errorMessage
Layer 3: Heartbeat watchdog
Every 15 seconds, HeartbeatWatchdog queries:
SELECT * FROM tasks
WHERE status = 'IN_PROGRESS'
AND last_heartbeat < NOW() - INTERVAL '30 seconds'
For each stale task:
→ Treats it as FAILED → triggers Layer 2 retry logic
The heartbeat timeout (30s) vs watchdog interval (15s) means a dead worker is detected within 15–45 seconds of its last heartbeat.
What happens when a reduce worker fails after the map phase completed?
- The reduce task is retried (up to 3 times)
- The intermediate map outputs are still in MinIO (
temp/{jobId}/map-{taskId}/) - The new reduce worker downloads the same intermediate files and reruns the reduce
- The map phase is NOT rerun
What happens when a map worker fails?
- The map task is retried
- The input data is still in MinIO (
users/{userId}/raw/...) - The new map worker downloads the same input and reruns map from scratch
All three services use the same multi-stage build pattern. The idea: compile in a fat JDK image, run in a slim JRE image.
Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
# copy root pom + all module dirs
COPY pom.xml .
COPY common/ common/
COPY manager-service/ manager-service/
# build only what we need (common is a dependency of manager-service)
RUN mvn -pl common,manager-service -am package -DskipTests -qThe -pl common,manager-service -am flag tells Maven to build only these modules (plus their dependencies with -am). Without -am, it would fail because it can't find common's compiled classes.
Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# non-root user for security — if someone gets RCE they're "appuser" not "root"
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --from=builder /build/manager-service/target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]Why Alpine? The Alpine Linux base is ~5MB vs ~30MB for Debian-based images. eclipse-temurin:21-jre-alpine is roughly 90MB vs 300MB+ for a full JDK image. Smaller images = faster pod starts and less storage.
Per-service differences:
| Service | -pl flag |
EXPOSE | ENTRYPOINT |
|---|---|---|---|
| ui-service | common,ui-service |
8080 | java -jar app.jar |
| manager-service | common,manager-service |
8081 | java -jar app.jar |
| worker | common,worker |
(none) | java -jar app.jar |
Worker has no EXPOSE because it never receives incoming connections — it only makes outbound HTTP calls to the manager and MinIO.
All manifests live in k8s/. There's a deploy.sh that applies them in the right order (namespace first, then infra, then apps).
# k8s/namespace/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: mapreduceEverything goes in this namespace. Services can refer to each other by their service name (e.g. manager-service, postgres, minio, keycloak) — Kubernetes DNS resolves these within the namespace.
# k8s/postgres/postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: mapreduce
spec:
replicas: 1
serviceName: postgres
selector:
matchLabels:
app: postgres
template:
spec:
containers:
- name: postgres
image: postgres:16-alpine
env:
- name: POSTGRES_DB → from ConfigMap
- name: POSTGRES_USER → from Secret
- name: POSTGRES_PASSWORD → from Secret
ports:
- containerPort: 5432
volumeMounts:
- mountPath: /var/lib/postgresql/data
name: postgres-data
volumeClaimTemplates:
- metadata: { name: postgres-data }
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5GiStatefulSet for Postgres gives it a stable pod name (postgres-0) and a persistent volume that survives pod restarts. If the pod dies and is rescheduled, it reconnects to the same volume.
The Service for Postgres is a ClusterIP on port 5432 — only reachable from inside the cluster (manager service).
# k8s/minio/minio-statefulset.yaml
# Similar pattern to Postgres
image: quay.io/minio/minio:latest
command: ["minio", "server", "/data", "--console-address", ":9001"]
ports:
- containerPort: 9000 # S3 API
- containerPort: 9001 # Console
# Service: NodePort
ports:
- name: api port: 9000 nodePort: 30900
- name: console port: 9001 nodePort: 30901MinIO is exposed via NodePort so you can access the web console from your browser at http://{minikube-ip}:30901.
# k8s/keycloak/keycloak-deployment.yaml
image: quay.io/keycloak/keycloak:25.0
args: ["start-dev"] # development mode, no TLS
env:
- KC_DB: postgres
- KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
- KC_DB_USERNAME / KC_DB_PASSWORD → from Secret
- KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD → from Secret
# Service: NodePort :30180Keycloak stores its own data in the same PostgreSQL instance but a different database (keycloak vs mapreduce).
# k8s/manager/manager-statefulset.yaml
kind: StatefulSet
replicas: 2
serviceName: manager-service
spec:
containers:
- name: manager-service
image: mapreduce/manager-service:latest
ports:
- containerPort: 8081
envFrom:
- configMapRef: { name: manager-config }
- secretRef: { name: manager-secret }
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8081 }
initialDelaySeconds: 60
periodSeconds: 10
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8081 }
initialDelaySeconds: 90
periodSeconds: 20
failureThreshold: 5
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { cpu: 1, memory: 1Gi }
serviceAccountName: manager-saThe serviceAccountName: manager-sa is what grants the manager permission to create K8s Jobs. Without it, the Fabric8 client would get a 403 when trying to create worker pods.
The Service for manager is a headless ClusterIP (no NodePort) — only the UI service can reach it, and only from inside the cluster.
# k8s/manager/manager-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: manager-sa
namespace: mapreduce
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: manager-job-role
namespace: mapreduce
rules:
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: manager-job-rolebinding
namespace: mapreduce
subjects:
- kind: ServiceAccount
name: manager-sa
namespace: mapreduce
roleRef:
kind: Role
name: manager-job-role
apiGroup: rbac.authorization.k8s.ioThis is a namespaced Role (not ClusterRole) — the manager can only create jobs in the mapreduce namespace, not cluster-wide. Least-privilege principle.
# k8s/ui/ui-deployment.yaml
kind: Deployment
replicas: 1
spec:
containers:
- name: ui-service
image: mapreduce/ui-service:latest
ports:
- containerPort: 8080
resources:
requests: { cpu: 125m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
initialDelaySeconds: 60
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
# Service: NodePort :30080UI uses Deployment (not StatefulSet) because it's stateless — any replica is identical. It only has 1 replica here but you could scale to 2+ easily.
Each service has its config split between a ConfigMap (non-sensitive) and a Secret (sensitive). Example for Manager:
# manager-configmap.yaml
data:
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: mapreduce
MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_ENDPOINT: http://192.168.49.2:30900
MINIO_BUCKET: mapreduce
K8S_NAMESPACE: mapreduce
WORKER_IMAGE: mapreduce/worker:latest
MANAGER_INTERNAL_URL: http://manager-service:8081
KEYCLOAK_ISSUER_URI: http://keycloak:8080/realms/mapreduce
KEYCLOAK_JWK_SET_URI: http://keycloak:8080/realms/mapreduce/protocol/openid-connect/certs
DEFAULT_MAP_TASKS: "4"
# manager-secret.yaml (values are base64-encoded)
data:
DB_USER: bWFuYWdlcg== # "manager"
DB_PASSWORD: ...
MINIO_ACCESS_KEY: ...
MINIO_SECRET_KEY: ...# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: mapreduce
POSTGRES_USER: mapreduce
POSTGRES_PASSWORD: mapreduce-secret
ports: ["5432:5432"]
volumes: [postgres-data:/var/lib/postgresql/data]
minio:
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin-secret
ports: ["9000:9000", "9001:9001"]
volumes: [minio-data:/data]
keycloak:
image: quay.io/keycloak/keycloak:25.0
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: mapreduce
KC_DB_PASSWORD: mapreduce-secret
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin-secret
ports: ["8180:8080"]
depends_on: [postgres]
manager:
build: { context: ., dockerfile: manager-service/Dockerfile }
environment:
DB_HOST: postgres
DB_USER: mapreduce
DB_PASSWORD: mapreduce-secret
MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_ENDPOINT: http://localhost:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin-secret
KEYCLOAK_ISSUER_URI: http://keycloak:8080/realms/mapreduce
K8S_NAMESPACE: mapreduce
WORKER_IMAGE: mapreduce/worker:latest
MANAGER_INTERNAL_URL: http://manager:8081
ports: ["8081:8081"]
depends_on: [postgres, minio, keycloak]
ui:
build: { context: ., dockerfile: ui-service/Dockerfile }
environment:
MANAGER_BASE_URL: http://manager:8081
KEYCLOAK_ISSUER_URI: http://keycloak:8080/realms/mapreduce
KEYCLOAK_ADMIN_BASE_URL: http://keycloak:8080
KEYCLOAK_ADMIN_USERNAME: admin
KEYCLOAK_ADMIN_PASSWORD: admin-secret
KEYCLOAK_REALM: mapreduce
ports: ["8080:8080"]
depends_on: [manager]
volumes:
postgres-data:
minio-data:With Docker Compose, workers can't actually be launched in Kubernetes (there's no K8s running). To test locally you'd either mock the K8s launcher or run a local Kubernetes (Minikube, Kind, etc.) and point the manager at it.
Flyway runs SQL migrations in order when the manager starts. The files live at:
manager-service/src/main/resources/db/migration/
V1__create_jobs_and_tasks.sql
V2__create_file_metadata.sql
-- jobs table
CREATE TABLE jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL, -- stores enum name: MAP_PHASE, etc.
code_path VARCHAR(500),
input_path VARCHAR(500),
output_path VARCHAR(500),
num_map_tasks INT NOT NULL DEFAULT 4,
num_reduce_tasks INT NOT NULL DEFAULT 2,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_jobs_user_id ON jobs(user_id);
CREATE INDEX idx_jobs_status ON jobs(status);
-- Auto-update updated_at on any row change
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();-- tasks table
CREATE TABLE tasks (
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE,
task_type VARCHAR(10) NOT NULL, -- MAP or REDUCE
status VARCHAR(20) NOT NULL, -- IDLE, IN_PROGRESS, COMPLETED, FAILED
worker_pod_id VARCHAR(255), -- K8s Job name: worker-map-{taskId}
input_split TEXT, -- comma-separated MinIO keys
output_location TEXT, -- MinIO path written by worker
retry_count INT NOT NULL DEFAULT 0,
last_heartbeat TIMESTAMP,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_job_id ON tasks(job_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE TRIGGER trg_tasks_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();CREATE TABLE file_metadata (
file_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
file_type VARCHAR(10) NOT NULL, -- DATA or CODE
original_name VARCHAR(500),
storage_path VARCHAR(500) NOT NULL, -- MinIO object key
size_bytes BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_file_metadata_user_id ON file_metadata(user_id);
CREATE INDEX idx_file_metadata_file_type ON file_metadata(file_type);Why store file metadata in Postgres at all? When a user submits a job with dataId and codeId, the manager needs to verify:
- Those files actually exist
- They belong to this user (not someone else's files)
- What their MinIO paths are (to give to the workers)
Without the metadata table, you'd have to list MinIO objects to find files, which is slow and doesn't give you ownership info.
Both services expose Actuator endpoints:
/actuator/health → combined health status
/actuator/health/readiness → is the app ready to handle requests?
/actuator/health/liveness → is the app alive (not deadlocked)?
/actuator/prometheus → Prometheus metrics scrape endpoint
Readiness vs Liveness:
- Readiness: "Can I take traffic right now?" — Spring waits until Flyway migrations finish, Keycloak JWKS is loaded, etc. K8s won't route traffic until this returns 200.
- Liveness: "Am I still functioning?" — if this starts returning errors, K8s kills the pod and restarts it.
The initial delays (initialDelaySeconds: 60) give the JVM and Spring time to start up before K8s starts checking.
Micrometer is Spring Boot's metrics facade. It auto-instruments:
- JVM: heap usage, GC pauses, thread counts
- HTTP: request count, error rate, response time (by endpoint)
- HikariCP: connection pool usage (for manager only)
- Spring Data JPA: query execution times
To actually see these metrics you'd run Prometheus (scraping /actuator/prometheus every 15s) and Grafana (displaying dashboards). Those aren't in this repo but you could add them to Docker Compose or Kubernetes.
The UI service has Springdoc OpenAPI configured:
@Configuration
@OpenAPIDefinition(info = @Info(title = "MapReduce API", version = "1.0"))
@SecurityScheme(
name = "Bearer Auth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT"
)
public class OpenApiConfig { }This auto-generates OpenAPI 3 spec from the controller annotations and exposes:
http://host:30080/swagger-ui.html— interactive UIhttp://host:30080/v3/api-docs— raw JSON spec
You can use the Swagger UI to test API calls directly in the browser (with Bearer token pasted in).
Here's the entire numbered flow for mr run data.txt wordcount.jar --reducers 2 --watch 3:
[Upload Phase]
[1] CLI: POST /api/v1/data multipart (data.txt)
[2] UI: validates JWT, extracts userId → POST /internal/files/data
[3] Manager: generates key = users/{userId}/raw/{uuid}_data.txt
[4] Manager: MinIO.putObject(key, stream, size)
[5] Manager: INSERT file_metadata → returns {id: dataUUID}
[6] CLI: saves session.lastDataId = dataUUID
[7] CLI: POST /api/v1/code multipart (wordcount.jar) → same flow → codeUUID
[Job Submission]
[8] CLI: POST /api/v1/jobs {dataId, codeId, numReducers: 2}
[9] UI: proxy to Manager POST /internal/jobs
[10] Manager: lookup file_metadata for dataId → get inputPath
[11] Manager: INSERT jobs (INITIALIZING) → jobId
[12] CLI: receives {jobId: "abc-123"} → start polling
[Map Phase - async]
[13] Manager: listObjects(inputPath) → ["key1.txt"]
[14] Manager: split into 4 chunks (each gets ~1/4 of keys)
[15] Manager: INSERT 4 tasks (MAP, IDLE, inputSplit=...)
[16] Manager: UPDATE job → MAP_PHASE
[17] Manager: k8sLauncher.launchWorker() × 4
K8s creates 4 batch/v1/Jobs → 4 pods start
[Worker Execution - per pod]
[18] Worker: reportInProgress() → manager updates task IN_PROGRESS
[19] Worker: starts heartbeat thread (every 10s)
[20] Worker: downloads code.jar from MinIO
[21] Worker: URLClassLoader(code.jar) → ServiceLoader.load(Mapper)
finds WordCountMapper via META-INF/services/...Mapper
[22] Worker: downloads input file(s) from MinIO
[23] Worker: for each line → mapper.map(key, line, emit)
WordCountMapper: splits by whitespace, emits ("word", "1") per word
[24] Worker: sorts intermediate k-v in TreeMap
[25] Worker: writes "word\t1\nword\t1\n..." to MinIO
key: temp/abc-123/map-{taskId}/part-0.txt
[26] Worker: reportCompleted(outputKey)
→ manager updates task COMPLETED, stores outputLocation
[27] All 4 workers complete → manager sees countCompleted == 4
[Reduce Phase - async]
[28] Manager: collects 4 output paths (temp/.../part-0.txt × 4)
[29] Manager: distributes across 2 reducers:
reducer0: [map-0/part-0.txt, map-2/part-0.txt]
reducer1: [map-1/part-0.txt, map-3/part-0.txt]
[30] Manager: INSERT 2 tasks (REDUCE, IDLE, inputSplit=...)
[31] Manager: UPDATE job → REDUCE_PHASE
[32] Manager: k8sLauncher.launchWorker() × 2
[Reduce Worker Execution]
[33] Worker: downloads code.jar (same jar as before)
[34] Worker: ServiceLoader.load(Reducer) → finds WordCountReducer
[35] Worker: downloads 2 intermediate files
[36] Worker: parses "word\t1" lines → groups by key:
{ "the" → ["1","1","1",...], "and" → ["1","1",...], ... }
[37] Worker: for each key → reducer.reduce(key, values, emit)
WordCountReducer: sum all "1"s → emit ("the", "892")
[38] Worker: writes "the\t892\nand\t654\n..." to MinIO
key: users/{userId}/results/abc-123/reduce-0.txt
[39] Worker: reportCompleted(outputKey)
[40] Both reduce workers complete → manager sees countCompleted == 2
[41] Manager: UPDATE job → COMPLETED
[Results]
[42] CLI poll: GET /api/v1/jobs/abc-123
[43] Manager: listObjects("users/{userId}/results/abc-123/")
→ ["reduce-0.txt", "reduce-1.txt"]
[44] Manager: presignedGetUrl("reduce-0.txt", 3600) × 2
[45] Returns {status: COMPLETED, outputUrls: ["http://...", "http://..."]}
[46] CLI prints: "Job completed. Results: mr results abc-123"
[mr results abc-123]
[47] CLI: GET each presigned URL directly from MinIO
[48] CLI: concatenates content of reduce-0.txt + reduce-1.txt
[49] CLI: parses "word\tcount" lines
[50] CLI: all values are numbers → sort by count descending
[51] CLI: renders table with bar chart
Results for job abc-123-...
156 unique entries · 12,450 total
KEY COUNT FREQUENCY
─────────────────────────────────────────────────────
the 892 ██████████████████████████████
and 654 █████████████████████░░░░░░░░░
a 421 █████████████░░░░░░░░░░░░░░░░░
...
ui-service |
manager-service |
worker |
cli |
|
|---|---|---|---|---|
| Framework | Spring Boot 3.3.4 | Spring Boot 3.3.4 | Spring Boot 3.3.4 (CommandLineRunner) | Plain Java 21 |
| Auth | OAuth2 Resource Server | OAuth2 Resource Server (split chains) | None (batch pod) | JWT Bearer header |
| Database | — | PostgreSQL + JPA + Flyway | — | ~/.mr/session.json |
| Storage | — | MinIO SDK (2 clients) | MinIO SDK | — |
| K8s client | — | Fabric8 v6.13.1 | — | — |
| CLI framework | — | — | — | picocli v4.7.6 |
| JSON | Jackson (via Spring) | Jackson (via Spring) | Jackson (via Spring) | Jackson v2.17.2 |
| DTO mapping | MapStruct | MapStruct | — | — |
| Metrics | Micrometer + Prometheus | Micrometer + Prometheus | — | — |
| API docs | Springdoc OpenAPI v2.6 | — | — | picocli --help |
| Port | 8080 (NodePort 30080) | 8081 (ClusterIP only) | no server port | local only |
| K8s kind | Deployment (1 replica) | StatefulSet (2 replicas) | batch/v1/Job (dynamic) | local binary |
| Docker base | eclipse-temurin:21-jre-alpine | eclipse-temurin:21-jre-alpine | eclipse-temurin:21-jre-alpine | fat JAR |
| Lifecycle | long-running server | long-running server | starts → runs → exits | interactive shell / one-shot |