Episode 1: Publish Story

This documents the complete request flow when an iOS user publishes a story (geo data) through the AdventureTube microservice architecture.


Design / Architecture

Architecture Diagram

Fast Gateway Rejection — The 401 response takes only 4.275ms with a single span, meaning the Gateway’s JWT validation catches expired tokens before any downstream service is invoked.

Automatic Token Refresh — The iOS app handles 401 transparently — detects the error, refreshes the token, and retries the original request without user intervention.

Async Processing via Kafka — The geo/save endpoint returns 202 (Accepted) immediately after producing to Kafka. The actual MongoDB write happens asynchronously via the Kafka consumer. Both producer and consumer spans are visible in Zipkin.

SSE for Real-time Status — Instead of polling, the iOS app opens an SSE stream to receive job status updates in real-time (PENDING → PROCESSING → COMPLETED/DUPLICATE).


Sequence Diagram

Publish Story Sequence Diagram

Step 5 has no SSE and no StoryJobStatus. The screenshot Kafka message is fire-and-forget — iOS already received SSE COMPLETED in Step 4. Screenshot generation uses its own ScreenshotJobStatus that iOS will poll later. See Episode 2: Chapter Screenshots for the full screenshot flow.


iOS Call Stack: Publish Flow

The complete method call path from user action to UI update:

uploadStory()                                      // AddStoryViewVM+Publishing.swift
│
├─ validaterAllContentsBeforeStoreToCoreData()      // check all fields filled
├─ fetch StoryEntity from Core Data                 // find by youtubeId
├─ createJsonFromStory(storyEntity:)                // serialize to JSON
│
├─ publishingStatus = .uploading
│
├─ publishStory(jsonData:)                          // AdventureTubeAPIService+Story.swift
│  │                                                // REST: POST /auth/geo/save
│  ├─ withTokenRefresh { accessToken in }            // auto 401 retry
│  │  └─ session.dataTaskPublisher(for: request)     // send HTTP request
│  │     └─ .tryMap → guard 200-299 → decode JSON
│  │                else → throw unauthorized/serverError
│  │
│  └─ .sink
│     ├─ receiveCompletion: { error → .failed }
│     │
│     └─ receiveValue: { response → got trackingId }
│        │
│        └─ startSSETracking(trackingId:, onCompleted:)
│           │
│           ├─ publishingStatus = .streaming
│           │
│           ├─ streamJobStatus(trackingId:)
│           │  ├─ SSEClient()
│           │  ├─ sseClient.connect(url:, headers:)
│           │  │  └─ URLSession(delegate: self)
│           │  │     └─ dataTask.resume()
│           │  └─ return sseClient.publisher
│           │     └─ .tryMap { data → JobStatusDTO }
│           │
│           └─ .sink
│              ├─ receiveCompletion: { error →
│              │     startPollingFallback()
│              │     └─ Timer.publish(every: 3.0)
│              │        └─ pollJobStatus() → handleJobStatus()
│              │  }
│              │
│              └─ receiveValue: { jobStatus →
│                    handleJobStatus(jobStatus, onCompleted:)
│                    │
│                    ├─ .COMPLETED → cancelSSEStream()
│                    │              → onCompleted(jobStatus)
│                    │                 ├─ isPublished = true
│                    │                 ├─ isStoryPublished = true
│                    │                 ├─ manager.save()
│                    │                 └─ publishingStatus = .completed → UI
│                    │
│                    ├─ .PENDING    → keep waiting
│                    ├─ .FAILED     → .failed(message:)
│                    └─ .DUPLICATED → .failed(message:)
│                 }

Java Call Stack: Publish Flow

The complete method call path from controller to database save + async screenshot trigger:

AdventureTubeDataController.save()                         // POST /geo/save
│
├─ trackingId = UUID.randomUUID().toString()
├─ jobStatusService.createPendingJob(trackingId)
│  └─ save StoryJobStatus(PENDING) to MongoDB
│
├─ producer.sendAdventureTubeData(trackingId, data)
│  └─ KafkaMessage(trackingId, CREATE, data)
│     └─ kafkaTemplate.send("adventuretube-data", json)    // async
│
└─ return 202 Accepted + ServiceResponse(pendingJob)

--- Kafka consumer thread (async) ---

StoryConsumer.consume(message)
│  └─ switch(CREATE) → handleSave(kafkaMessage, trackingId)
│
StoryConsumer.handleSave()
│
├─ adventureTubeDataService.save(data)
│  └─ adventureTubeDataRepository.save(data)                // MongoDB insert
│
├─ jobStatusService.markCompleted(trackingId)
│  └─ SseEmitterManager sends COMPLETED event               // → iOS SSE
│
└─ producer.sendScreenshotRequest(youtubeContentID, data)
   └─ kafkaTemplate.send("adventuretube-screenshots")       // async, no SSE

Complete Request Flow (Zipkin Traces)

Step 1: Initial POST — Token Expired (401)

POST /auth/geo/save401 CLIENT_ERROR (4.275ms, 1 span)

Property Value
Duration 4.275ms
Services gateway-service only
Spans 1
Status 401 CLIENT_ERROR

The iOS app sends the publish request with an expired JWT access token. The Gateway rejects it immediately — the request never reaches auth-service or any downstream service.

Step 2: Token Refresh (177ms)

POST /auth/token/refresh200 SUCCESS (177.184ms, 11 spans)

Service Span Duration
gateway-service http post /auth/token/refresh 177.184ms
auth-service http post /auth/token/refresh 155.147ms
auth-service http post (findtoken) 27.791ms
member-service http post /member/findtoken 16.441ms
auth-service http post (storetokens) 121.238ms
member-service http post /member/storetokens 119.018ms

Step 3: Retry POST — Publish Accepted (115ms)

POST /auth/geo/save202 ACCEPTED (115.044ms, 11 spans)

Service Span Duration
gateway-service http post /auth/geo/save 85.134ms
auth-service http post /auth/geo/save 43.009ms
geospatial-service http post /geo/save 26.796ms
geospatial-service adventuretube-data send 38.509ms
geospatial-service adventuretube-data receive 20.265ms

Step 4: SSE Status Stream (47ms)

GET /auth/geo/status/stream/{trackingId}200 SUCCESS (47.165ms, 9 spans)

Service Span Duration
gateway-service http get /auth/geo/status/stream/{trackingId} 46.735ms
auth-service http get /auth/geo/status/stream/{trackingId} 32.788ms
geospatial-service http get /geo/status/stream/{trackingId} 19.976ms

Performance Summary

Step Endpoint Status Duration Spans Services
1. Initial Save POST /auth/geo/save 401 4.275ms 1 1 (gateway)
2. Token Refresh POST /auth/token/refresh 200 177.184ms 11 3 (gw → auth → member)
3. Retry Save POST /auth/geo/save 202 115.044ms 11 4 (gw → auth → geo → kafka)
4. SSE Status GET /auth/geo/status/stream/{id} 200 47.165ms 9 3 (gw → auth → geo)
Total 343.668ms 32

Tech Stack

Layer Technology Details
iOS Client Swift URLSession, SSE streaming, JWT token management
API Gateway Spring Cloud Gateway Routing, JWT validation, rate limiting
Auth Service Spring WebFlux (Netty) Reactive, non-blocking — proxies to member/geo services
Member Service Spring MVC (Tomcat) JPA/JDBC → PostgreSQL, Java 21 virtual threads
Geospatial Service Spring MVC (Tomcat) MongoDB, Kafka producer/consumer
Message Broker Apache Kafka Async event streaming between services
Tracing Zipkin + Micrometer Distributed tracing across all services

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top