Skip to content

Streaming mapper, CQRS read-replica, shuffle integrity, benchmarks, and bug fixes - #334

Merged
CodeMaestro1 merged 7 commits into
mainfrom
pr/benchmarks
May 27, 2026
Merged

CodeMaestro1 merged 7 commits into
mainfrom
pr/benchmarks

Conversation

@Brilliafy

@Brilliafy Brilliafy commented May 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR introduces shuffle data integrity checks, CQRS read-replica support, orphaned upload mitigation, a streaming mapper pipeline, and infrastructure bug fixes to the KubeMapReduce platform.


Changes by Category

🔧 Bug Fixes

  • Gateway routing (k8s/60-gateway.yaml): Corrected backendRef from ui:8080 to api:8081. The gateway was pointing to a non-existent "ui" service; the actual service is named "api" on port 8081. Without this, all HTTPRoute traffic through the gateway silently failed.

  • SpawnWorker implementation (orchestrator.go): SpawnWorker was a return nil stub — per-attempt worker isolation was broken. Now creates a full K8s Job per task attempt with proper labels, security context, and volume-mounted secrets.

  • DeleteWorkerJob implementation (orchestrator.go): Was a return nil stub. Now performs label-based task_id deletion of K8s Jobs, preventing zombie Job accumulation.

  • EnsureWorkerPool idempotency (orchestrator.go): When a Job already exists, the old code tried to Update it — but K8s Jobs have immutable .spec.completions, causing API errors. Changed to silent idempotent return.

  • Default split size (handlers.go): Reduced from 64MB to 1MB. 64MB splits caused OOM on small clusters; 1MB aligns with actual corpus sizes.

🏗️ Architecture: Deployment → K8s Job Migration

  • Worker pools now use batch/v1 Jobs instead of apps/v1 Deployments. Workers are batch workloads — Jobs provide explicit lifecycle control (RestartPolicyNever, BackoffLimit=0) so retry authority stays in Manager logic.

  • Per-attempt worker spawning: Each task attempt gets an isolated K8s Job with task_id/attempt_id/job_id labels, enabling fine-grained lifecycle management.

  • Scheduler updated: ScheduleJob, FailStaleTasks, and Recover now use spawnSchedulableWorkers (iterates GetNextTask → SpawnWorker) instead of pool-based EnsureWorkerPool. Removed legacy DispatchTask pull-based dispatch.

🛡️ Shuffle Data Integrity

  • Protocol change (proto/mapreduce.proto): Added segment_checksum (SHA-256 hex string) and segment_end (bool) to ShuffleDataChunk. Every segment now gets a checksummed delimiter, including the final one.

  • Server-side (grpc/shuffle.go): Computes SHA-256 per segment and emits it in the delimiter chunk. Previously only non-final segments got delimiters.

  • Client-side (worker/reduce.go): downloadShuffleInputsFromService validates each segment's SHA-256 against expected checksum from DataLocations URI fragments. Mismatch → hard failure preventing silent data corruption.

📊 CQRS Read-Replica Support

  • Config (config.go): Added DatabaseReadOnlyDSN env var for optional read-replica connection string.
  • Store (api/store.go): NewPostgresJobStoreWithReadReplica constructor + readDB() helper. All read queries (GetJob, ListJobs, GetJobOutputs, ListAllJobs) route through the replica pool when configured.
  • API main (cmd/api/main.go): Opens separate read-only pool at startup when DSN differs from primary.

⏰ Orphaned Upload Mitigation

  • MinIO lifecycle policy (k8s/20-minio.yaml, cmd/api/main.go, cmd/manager/main.go): 24-hour expiry ILM rule on temp/ prefix of mapreduce-inputs bucket. Orphaned uploads from failed CLI submissions are auto-garbage-collected without app-side cleanup.

📦 Streaming Mapper Pipeline

  • worker/map.go: Converted from batch to streaming pipeline. Old: load all splits into RAM → execCode → parse all JSONL → sort → optional combiner → repartition. New: streaming read → streaming exec → spilling sorter per record → finalize → combiner (same streaming pattern). Prevents OOM on large datasets.

  • worker/split.go: readSplitRecords (returns [][]byte) → readSplitRecordsStreaming (returns io.ReadCloser via io.Pipe). Streams from MinIO through to mapper without buffering entire splits.

  • worker/download.go: Changed from os.OpenFile with O_EXCL (symlink-vulnerable) to os.CreateTemp (secure random suffix). Properly sets 0755 permissions via os.Chmod.

🔐 Auth/CLI Improvements

  • Idempotent user creation (auth-service/cmd/setup/main.go): Handles 409 conflict — rerunning setup doesn't fatally error.
  • Keycloak URL persistence (token_store.go, login.go, http.go): KeycloakBaseURL saved during login and used for token refresh, preventing refresh failures when env vars change between sessions.
  • HTTP context fix (http.go): Changed from cliRequestContext() (timeout-bounded) to context.Background() so response bodies can be fully read without premature cancellation.

☸️ Infrastructure Updates

  • Worker RBAC: Added update/patch verbs required by orchestrator.
  • MinIO setup Job: Added --config-dir /tmp/.mc for read-only root filesystem compatibility.

📝 Documentation

  • README.md: Restructured with architecture overview, repo layout, CLI reference, REST API table, gRPC contract, and links to dedicated docs.
  • benchmarks/README.md: Expanded with WordCount/Grep local validation steps and mapper/combiner/reducer protocol docs.
  • docs/MINIKUBE_LOCAL_DEV.md: Keycloak issuer auto-detection, migration script usage, NodePort mode instructions.
  • migrations/README.md: Migration script with namespace/pod overrides.

✅ Testing

  • New unit tests for shuffle checksum delivery/validation, CQRS store constructor, admin config GET handler, and streaming split pipeline.
  • Removed t.Skip from previously-stubbed SpawnWorker/DeleteWorkerJob tests.
  • Updated scheduler tests for new per-attempt spawning flow.

Checklist

  • go fmt ./... passes
  • go vet ./... passes
  • go test -race -cover ./... passes
  • go mod tidy clean

Brilliafy added 3 commits May 23, 2026 16:32
- Revert hardcoded IP addresses to proxy URLs / secret references
- Re-add benchmarks section without duplicating existing sections
- Add GET /api/v1/admin/config/workers to REST API table
…, and comprehensive benchmarks

- Streaming mapper pipeline (split -> exec -> spill -> sort -> combiner -> partition)
  prevents OOM on large datasets by streaming through bounded buffers
- CQRS read-replica support (DATABASE_READONLY_DSN) routes read queries to
  a separate pool, isolating them from the write path
- SHA-256 shuffled segment checksums provide end-to-end data integrity
- MinIO 24h temp/ expiry lifecycle policy mitigates orphaned uploads
- Auth/CLI: KeycloakBaseURL persistence, idempotent user creation, HTTP context fix
- Bug fixes: gateway backendRef (ui:8080 -> api:8081), SpawnWorker/DeleteWorkerJob
  no longer stubs, default split size 64MB -> 1MB
- Worker pool migration from Deployment to K8s Job for explicit lifecycle control
- Comprehensive benchmark suite (WordCount, Grep, combiner validation, Apache Bench)
- GKE cluster deployment guide and extended benchmark analysis
- CloudBuild CI/CD configs and job-fail test fixtures

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates KubeMapReduce across Worker, Manager, CLI/Auth, Kubernetes manifests, and docs/benchmarks to improve reliability and operability—most notably by moving the Worker’s Map path to a streaming pipeline, tightening artifact handling, and expanding deployment/benchmarking material for GKE.

Changes:

  • Worker: switch Map execution to a streaming mapper → spilling sorter pipeline; add split streaming reader; harden code download path generation.
  • Manager/K8s: adjust scheduler recovery/respawn behavior around worker pools; expand/modify GKE-oriented manifests (Gateway API, RBAC, external LBs, image rewrites, DB init ConfigMap generation).
  • CLI/Auth + docs/benchmarks: persist Keycloak base URL in stored tokens; adjust CLI request context usage; add/expand benchmark scripts and deployment/benchmark documentation.

Reviewed changes

Copilot reviewed 56 out of 63 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
worker-service/internal/worker/worker_test.go Updates worker tests to use the new streaming exec hook (execCodeStream).
worker-service/internal/worker/split.go Introduces readSplitRecordsStreaming and pipes split data into consumers.
worker-service/internal/worker/split_test.go Updates tests for the new streaming split reader.
worker-service/internal/worker/spill.go Changes default spill threshold (now 64MiB) for map-output sorting.
worker-service/internal/worker/reduce_test.go Updates reduce tests to use execCodeStream.
worker-service/internal/worker/map.go Reworks map execution to stream mapper output into a spilling sorter (and combiner pipeline).
worker-service/internal/worker/download.go Uses os.CreateTemp + chmod for safer code download file creation.
worker-service/internal/worker/download_code_test.go Adjusts tests for randomized temp filenames.
testdata/job1-wordcount/mapper.py Makes mapper more robust to non-dict JSON and type errors.
testdata/job-fail/reducer.py Adds reducer fixture for failure/retry scenarios.
testdata/job-fail/mapper.py Adds mapper fixture that intentionally fails on first attempt and randomly later.
testdata/job-fail/input_with_fail.jsonl Adds input corpus for retry/failure-injection tests.
README.md Expands benchmark/deployment links and adds an admin config GET endpoint row.
manager-service/internal/manager/scheduler.go Changes recovery and scheduling behavior to ensure worker pools; removes per-attempt spawning flow.
manager-service/internal/manager/scheduler_test.go Updates scheduler unit tests to match revised retry/spawn logic.
manager-service/internal/manager/scheduler_e2e_test.go Updates scheduler E2E expectations for revised recovery flow.
manager-service/internal/manager/orchestrator.go Makes EnsureWorkerPool idempotent on AlreadyExists; adds creation logging.
manager-service/internal/grpc/server_test.go Updates gRPC server tests after retry/redispatch logic changes.
manager-service/internal/api/handlers.go Reduces default split target size from 64MiB to 1MiB.
manager-service/cmd/manager/main.go Allows overriding worker image via WORKER_IMAGE.
k8s/minio-lb.yaml Adds external LoadBalancer Service for MinIO.
k8s/migrations/0002_add_locality_config.sql Adds locality-related SYSTEM_CONFIG columns (k8s copy).
k8s/migrations/0001_initial_schema.sql Adds initial schema migration (k8s copy).
k8s/kustomization.yaml Adds image rewrites and generates postgres-init ConfigMap from migration files.
k8s/keycloak-lb.yaml Adds external LoadBalancer Service for Keycloak.
k8s/api-lb.yaml Adds external LoadBalancer Service for API.
k8s/60-gateway.yaml Updates Gateway class name and routes to backend services.
k8s/59-gateway-class.yaml Adds a GatewayClass resource (currently inconsistent with Gateway usage).
k8s/50-worker-rbac.yaml Expands RBAC verbs/resources and adds node-view ClusterRole(+Binding).
k8s/35-api.yaml Switches API image to Artifact Registry and sets pull policy Always.
k8s/30-manager.yaml Switches Manager image to Artifact Registry; adds WORKER_IMAGE; modifies MinIO endpoint config.
k8s/26-auth-setup.yaml Adds a Keycloak bootstrap/setup Job manifest.
k8s/20-minio.yaml Changes MinIO PVC access mode; adjusts mc invocation for RO rootfs compatibility; adds ILM rule.
k8s/10-postgres.yaml Removes placeholder postgres-init ConfigMap (now generated by kustomize).
docs/PRESENTATION_TESTS.md Adds presentation-focused performance/reliability results doc.
docs/EXTENDED_BENCHMARKS.md Adds extended benchmark and resilience proof doc.
docs/DEPLOYMENT.md Rewrites deployment guide focused on GKE + Artifact Registry + benchmarks.
cloudbuild-worker.yaml Adds Cloud Build config for worker image build.
cloudbuild-manager.yaml Adds Cloud Build config for manager image build.
cli-service/cmd/cli/login.go Saves Keycloak base URL in stored token file during login.
cli-service/cmd/cli/http.go Uses stored Keycloak base URL for refresh; switches doAuthRequest to Background context.
benchmarks/run_cluster_benchmarks.py Adds a cluster benchmark runner script (was stubbed).
benchmarks/run_benchmarks.py Adds local benchmark runner script.
benchmarks/results/recovery_metrics.json Adds benchmark output artifact.
benchmarks/results/local_results.json Adds benchmark output artifact.
benchmarks/results/grep_benchmark.json Adds benchmark output artifact.
benchmarks/results/e2e_recovery_logs.txt Adds E2E recovery logs artifact.
benchmarks/results/e2e_failure_logs.txt Adds E2E failure logs artifact.
benchmarks/results/distributed_benchmark.json Adds distributed benchmark output artifact.
benchmarks/results/advanced_metrics.json Adds benchmark output artifact.
benchmarks/results/ab_test.txt Adds ApacheBench output artifact.
benchmarks/README.md Expands benchmark instructions and adds GKE run guidance.
benchmarks/distributed_benchmark.py Adds an automated distributed benchmark runner that submits jobs via the CLI.
auth-service/pkg/auth/token_store.go Extends stored tokens with KeycloakBaseURL.
auth-service/pkg/auth/token_store_test.go Updates token store tests for the new field.
auth-service/cmd/setup/main.go Makes user creation idempotent by tolerating “already exists” errors.
.gitignore Adds ignores for local scratch/demo files and some test inputs.

Comment on lines 21 to +24
// defaultSpillThresholdBytes is the fallback in-memory budget when the caller
// passes a non-positive threshold. It mirrors the production default of
// MAP_SORT_SPILL_THRESHOLD_MB=256.
const defaultSpillThresholdBytes int64 = 256 * 1024 * 1024
const defaultSpillThresholdBytes int64 = 64 * 1024 * 1024
Comment on lines +25 to +27
// readSplitRecordsStreaming returns an io.Reader that streams JSONL records from
// MinIO with boundary alignment. It avoids buffering the entire split in RAM.
func readSplitRecordsStreaming(ctx context.Context, storage objectStorage, dataURI string, byteStart, byteEnd int64, checksum string) (io.ReadCloser, error) {
Comment on lines 124 to 131
func TestReadSplitRecords_ExtendsPastChunkUntilNewline(t *testing.T) {
store := newChunkingStorage(int(splitReadChunkSize))
store := newMockStorage()
largeValue := strings.Repeat("x", int(splitReadChunkSize)+128)
record := fmt.Sprintf(`{"key":"k","value":"%s"}`+"\n", largeValue)
store.put("mapreduce-inputs", "big.jsonl", []byte(record))

lines, err := readSplitRecords(context.Background(), store, "s3://mapreduce-inputs/big.jsonl", 0, splitReadChunkSize/2, "")
rc, err := readSplitRecordsStreaming(context.Background(), store, "s3://mapreduce-inputs/big.jsonl", 0, 0, "")
if err != nil {
Comment on lines +768 to +781
// After tx.Commit(), ensure worker pool is running.
slog.InfoContext(ctx, "ScheduleJob: triggering worker pool", slog.String("job_id", req.JobID))
// Using a default of 4 workers or MTasks if smaller.
numWorkers := 4
if req.MTasks < numWorkers {
numWorkers = req.MTasks
}
spawnCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.orchestrator.EnsureWorkerPool(spawnCtx, req.JobID, numWorkers, s.managerAddr); err != nil {
slog.ErrorContext(ctx, "failed to ensure worker pool",
slog.String("job_id", req.JobID),
slog.Any("err", err))
}
Comment thread k8s/30-manager.yaml
Comment on lines 154 to 158
- name: DATABASE_DSN
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@postgres.mapreduce.svc.cluster.local:5432/$(POSTGRES_DB)?sslmode=require"
- name: MINIO_ENDPOINT
valueFrom:
secretKeyRef:
name: minio-creds
key: S3_ENDPOINT
value: "34.88.90.202:9000"
- name: MINIO_ACCESS_KEY
Comment thread k8s/60-gateway.yaml
Comment on lines 20 to 22
spec:
gatewayClassName: mapreduce
gatewayClassName: gke-l7-regional-external-managed
listeners:
Comment on lines +1 to +3
-- migrations/0003_add_locality_config.sql
--
-- Adds support for data locality-aware scheduling.
Comment thread benchmarks/run_cluster_benchmarks.py Outdated
Comment on lines +20 to +54
def submit_job(name, mapper, reducer, input_file, reducers=1):
print(f" Submitting {name} (R={reducers})...")
# This is a simplified version of submission via CLI
# In a real scenario, we might use the HTTP API directly for better control
# or ensure the CLI is properly configured.
start_time = time.time()

# Example CLI command:
# kubemr jobs submit --input corpus.jsonl --mapper s3://... --reducer s3://... --reducers 4
# We will assume files are already uploaded.

# For benchmarking, we might want to capture the Job ID and poll
# But for now, let's just mock the logic or use a placeholder
pass

def main():
parser = argparse.ArgumentParser()
parser.add_argument("--api-url", default=API_URL)
args = parser.parse_args()

print(f"--- Running Cluster Benchmarks against {args.api_url} ---")

# 1. Login
print("Logging in...")
# go run ./cli-service/cmd/cli login --username platform-admin --password admin
# Assuming bin/cli is built

# 2. Upload corpus and scripts
print("Uploading artifacts...")

# 3. Sweep Reducer Counts
reducer_counts = [1, 2, 4, 8]
for r in reducer_counts:
# submit_job(...)
pass
Comment on lines 107 to +117
if err := client.CreateUser(ctx, auth.CreateUserRequest{
Username: strings.TrimSpace(*username),
Email: strings.TrimSpace(*email),
Password: userPassword,
Role: normalizedRole,
}); err != nil {
log.Fatalf("failed to create user: %v", err)
if strings.Contains(err.Error(), "status 409") {
fmt.Printf("==> User %q already exists, skipping user creation.\n", strings.TrimSpace(*username))
} else {
log.Fatalf("failed to create user: %v", err)
}
Comment on lines +1361 to +1378
// Ensure worker pool for jobs that have tasks to respawn.
uniqueRespawnJobs := make(map[string]struct{})
for _, rec := range respawnTasks {
uniqueRespawnJobs[rec.jobID] = struct{}{}
}
for jobID := range uniqueRespawnJobs {
s.spawnSchedulableWorkers(ctx, jobID)
spawnCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// Delete any stale worker pool Job first — when a pod is force-deleted
// the K8s Job survives and EnsureWorkerPool treats AlreadyExists as
// success, leaving us with zero running pods and no recovery.
if err := s.orchestrator.CancelJob(spawnCtx, jobID); err != nil {
slog.WarnContext(ctx, "failed to cancel stale worker jobs before reaper respawn",
slog.String("job_id", jobID),
slog.Any("err", err),
)
}
if err := s.orchestrator.EnsureWorkerPool(spawnCtx, jobID, 4, s.managerAddr); err != nil {
slog.ErrorContext(ctx, "failed to ensure worker pool during reaper respawn",
64MB is the standard split size for distributed MapReduce systems -
it reduces the number of mappers and shuffle rounds compared to 1MB,
improving overall throughput on large datasets. Smaller clusters
can override via the --reducers flag.
@Brilliafy

Copy link
Copy Markdown
Collaborator Author

Split size revert: 1MB → 64MB

After further consideration I've restored the default split size to 64MB (defaultTargetSplitSizeBytes). Here's the reasoning:

  • 64MB is the Hadoop/MapReduce standard — it balances mapper parallelism against shuffle overhead. 1MB creates too many tiny partitions, increasing scheduler pressure and shuffle network round-trips.
  • Large datasets benefit more from fewer, larger splits — the shuffle merge phase has fewer sorted runs to combine, reducing disk I/O.
  • The 1MB experiment was useful for testing on our small GKE corpus, but 64MB is the correct default for distributed workloads. Users processing small files can always override with --reducers or adjust the constant per deployment.

This aligns with Google's MapReduce paper (Dean & Ghemawat, 2004) which recommends 64MB as the default split size for optimal throughput.

- spill.go: Fix comment to match 64 MiB default
- split.go: Clarify streaming vs full-download behavior in doc
- split_test.go: Add range boundary test for readSplitRecordsStreaming
- scheduler.go: Extract defaultReaperPoolSize constant, use ctx
- keycloak_admin.go: Add ErrUserAlreadyExists sentinel error
- auth-setup main.go: Use errors.Is for 409, support SETUP_USER_PASSWORD
- k8s/30-manager.yaml: MINIO_ENDPOINT uses secretKeyRef
- k8s/26-auth-setup.yaml: Remove hardcoded password from args
- k8s/50-worker-rbac.yaml: Remove duplicate YAML separator
- k8s/60-gateway.yaml: Use mapreduce GatewayClass consistently
- k8s/kustomization.yaml: Reference top-level migrations/
- k8s/migrations: Remove duplicate (top-level migrations/ is canonical)
- benchmarks/run_cluster_benchmarks.py: Remove stub script
@Brilliafy

Copy link
Copy Markdown
Collaborator Author

Fixes applied for Copilot review comments

Addressed all 13 inline review comments from copilot-pull-request-reviewer:

Go service fixes

  • spill.go: Fix comment to match 64 MiB default (was claiming 256)
  • split.go: Clarify streaming vs full-download behavior in doc comment
  • split_test.go: Add TestReadSplitRecords_RangeBoundary exercising boundary alignment
  • scheduler.go: Extract defaultReaperPoolSize constant instead of bare 4; use parent ctx instead of context.Background()
  • keycloak_admin.go: Add ErrUserAlreadyExists sentinel error for proper error handling
  • auth-setup main.go: Use errors.Is(err, auth.ErrUserAlreadyExists) instead of brittle string matching; support SETUP_USER_PASSWORD env var

K8s manifest fixes

  • 30-manager.yaml: MINIO_ENDPOINT uses secretKeyRef from minio-creds/S3_ENDPOINT instead of hardcoded IP
  • 26-auth-setup.yaml: Remove hardcoded --password=admin from args, use SETUP_USER_PASSWORD env var
  • 50-worker-rbac.yaml: Remove duplicate YAML document separator
  • 60-gateway.yaml: Use mapreduce GatewayClass to match 59-gateway-class.yaml
  • kustomization.yaml: Reference top-level ../migrations/ instead of duplicate k8s/migrations/

Cleanup

  • Remove benchmarks/run_cluster_benchmarks.py (stub with pass placeholders)
  • Remove k8s/migrations/ (duplicate of canonical top-level migrations/)
  • k8s/migrations/0002_add_locality_config.sql: Fix comment header from 0003 to 0002

…eaks

Recover() resets In-Progress tasks to Idle but never fails the old
Running attempts. After a manager restart, these leaked attempts
accumulate with status='Running' forever, consuming quota.

Add QueryFailRunningAttemptsByReplica and call it at the start of
Recover() so old attempts are properly Failed before tasks are
returned to Idle for reassignment.
@Brilliafy

Copy link
Copy Markdown
Collaborator Author

Bug fix: leaked Running attempts after manager restart

Root cause: Recover() called QueryResetTasksForReplica which sets In-Progress tasks back to Idle with current_attempt_id = NULL, but never failed the old Running attempt records. After each manager restart, leaked attempts with status='Running' accumulated in TASK_ATTEMPTS, gradually consuming the max_concurrent_pods quota until all new jobs failed with global worker pod quota exceeded.

Fix: Added QueryFailRunningAttemptsByReplica that fails all Running attempts whose tasks belong to the recovering replica. Called at the start of Recover() so old attempts are properly terminated before tasks are returned to Idle.

Files changed:

  • queries.go: new QueryFailRunningAttemptsByReplica query
  • scheduler.go: call it at the top of Recover()
  • scheduler_test.go, scheduler_e2e_test.go: updated mock expectations

@CodeMaestro1
CodeMaestro1 merged commit f755a61 into main May 27, 2026
3 checks passed
@CodeMaestro1
CodeMaestro1 deleted the pr/benchmarks branch May 27, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants