Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ target
.ensime
.ensime_lucene
.ensime_cache
.metals
.bloop
.vscode
metals.sbt
TAGS
\#*#
*~
Expand Down
276 changes: 276 additions & 0 deletions forex-mtl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
# Forex Proxy Service

## Problem Statement

Build a caching proxy that serves 10,000+ exchange-rate requests per day from an upstream API limited to 1,000 requests per day, with rates no older than 5 minutes.

### Constraints

| Constraint | Value |
|---|---|
| One-Frame API rate limit | **1,000 requests/day** per token |
| Max pairs per One-Frame request | **320** (determined by local load testing) |
|Currencies to support | Total 170 currencies |
| Proxy traffic requirement | **10,000+ requests/day** |
| Max rate staleness | **5 minutes** |

## Problem Analysis

### Theoretical Minimum

With a 5-minute cache TTL, rates must be refreshed at least every 5 minutes so total 24 * 60 / 5 = **288 API calls / day minimum**.

**Can we fetch all potential currency pairs on each batch api calls so all requests will hit cache?**

No, the maximum batch size is 320 but there are at least potential 170 * 169 = 28730 pairs to fetch.

**Gap to close:** 288 (minimum) → 1,000 (budget) gives us **712 API calls of headroom** for discovery, long-tail pairs, and traffic bursts.

### Naive Caching Approach
A naive reactive cache that fetches individual pairs and set cache by pair later on cache miss won't meet the 1000 API calls/ day requirements.

Each pair will cost 288 apis per day. Only 5 Pairs will over the limit.


### Traffic Distribution Assumptions

We can assume that the pairs between top 10 currencies contribute the 85% traffic. And the rest 15% traffic are the long-tail pairs.

We can have assumptions below:

```
Total requests: 10,000 / day

Popular pairs (top 10 currencies):
- Currencies: USD, EUR, JPY, GBP, AUD, CNY, CAD, CHF, SGD, NZD
- Pairs: 90 ordered pairs
- Traffic: 85% = 8,500 requests/day
- Avg requests per pair: ~94 requests/pair/day

Long-tail pairs (all others):
- Unique Pairs: ~150 unique pairs (1500 requests with 10x duplication)
- Avg requests per pair: ~10 requests/pair/day
```

## Core Problem Analysis

### Solution for popular pairs - Easy to Cache

90 popular pairs with 8,500 requests/day:
```
90 pairs < 320 batch limit

Fetch all 90 pairs in ONE batch request per refresh cycle

API calls: 288 cycles × 1 request = 288 calls/day
Cache hit rate: (8,500 − 288) / 8,500 ≈ 96.6%

Within each 5-min window:
- First request (any pair) -> triggers fetch of all 90 pairs
- Next ~29 requests → cache hits
```

### Long-Tail Pairs - The Bottleneck

~150 long-tail pairs with 1,500 requests/day:

**Problem:** Long-tail pairs have MUCH lower cache hit rates:

```
150 unique pairs for 1500 requests / day -> ~10 requests/pair/day on average

Inter-request interval: 1440 min / 10 = 144 minutes >> 5mins cache TTL

```

**Core Problem:** Long-tail pairs have low request frequency relative to 5-minute cache TTL, causing high cache miss rates and excessive API calls.

## Solution Design: Dynamic popular & long-tail pairs combination caching

### Key Observation
We're already fetching 90 popular pairs every 5 minutes (288 times/day). The One-Frame API supports **up to 320 pairs per request**

**Opportunity:** There are **230 spare slots** (320 − 90) in each batch request. Batching long-tail and popular pairs together can save api calls.


### Algorithm Design

Keeping Pairs Fetching State:
- popular_pairs: Fixed set of 90 pairs
- encountered_longtail: Set of fetched long-tail pairs

On cache miss request:
1. Fetch: POPULAR_PAIRS & encountered_longtail & requested_pair
2. Cache all fetched pairs (5-min TTL)
3. Add requested_pair to encountered_longtail

Batch size: 90 popular + ~150 dicovered = 240 pairs / request

240 < 320 limit -> work under constrain


### API call budget calculation

Popular Pairs minimum -> 288 requests / day
Long-tail pairs on first request -> 150 request / day

Total 438 request / day -> Meet 1000 requests / day


## Implementation Options

### 1. Option 1: Reactive Lazy Batch Fetching
Fetch on cache miss, batch all known pairs (popular pairs & encountered longtail paris & requested pair) together

```
pseudo code
if requested_pair in cache
return cache[requested_pair]
else:
batch = popular_pairs + encountered_longtail + requested_pair
rates = fetch_rates_from_api(batch)
cache.setAll(rates, ttl = 5.minutes)
encountered_longtail.add(requested_pair)
return cache[requested_pair]
```

Pros:
- Simple implementation and low complexity
- No fetching when no request coming
- Simple architecture & minimal dependencies: only cache + HTTP client
- Lowest API usage with high API quota room
- Self learning patterns automatically

Cons:
- Thundering herd risk
- No pre-warm cache
- First request for new pair has higher latency


### Option 2: Proactive Periodic Background Refresh
Background scheduler refreshes all pairs every 4 minutes. Make request for new pair

```
pseudo code
Scheduler every 4 minuts:
batch = popular_pairs + encountered_longtail + requested_pair
rates = fetch_rates_from_api(batch)
cache.setAll(rates, ttl = 5.minutes)

On request:
if cache[requested_pair]:
return cache[requested_pair]
else:
rate = fetch_single_rate_from_api(requested_pair)
cache.set(requested_pair, rate, ttl=300)
encountered_longtail.add(requested_pair)
return rate
```

Pros:
- Popular & encountered pairs always hit cache
- Distributed system ready

Cons:
- Higher complexity: Background scheduler
- Wasted refreshes if no traffic

## Final Decision

Chosen Approach: Option 2 - Reactive Lazy Batch Fetching

** Rationale:**
1. API useage meets 1000 calls/days
- Potentially only 288 calls / day (Theoretical Minimum)

2. Simplest architecture:
- No background scheduler
- minimal dependencies

3. Self-optimizing
- Automatically learns request patterns
- Adapts to changing traffic

## Architecture

```
Client -> RatesHttpRoutes -> Program (cache logic) -> OneFrameLive (HTTP client)
| |
RatesCache One-Frame API
|
CacheClient (in-memory)
```

- **OneFrameLive** - Pure HTTP client for the One-Frame API. No caching logic.
- **Program** - Orchestrates cache check, batch refresh, and thundering herd protection.
- **RatesCache** - Domain adapter that maps `Rate.Pair` to cache keys and `Rate` to JSON values.
- **CacheClient** - Generic key-value store with TTL (Redis-like interface). Currently backed by an in-memory `Ref`.

## Key Design & Considerations

### Thundering herd protection

When multiple requests comes in for the same pair and no cache not hit, then there could be multiple API request sent

Using a `Semaphore(1)` with double-checked locking. Cache hits are never blocked. On cache miss, only one request refreshes while others wait, then find fresh data in cache.

### Generic cache client interface

`CacheClient` has a Redis-like interface (`get`, `set` with TTL, `keys`). This makes it straightforward to swap `InMemoryCacheClient` for Redis in a multi-pod deployment.

### Per-route error handling

Each route wraps its response in a route-specific error handler keeping error-to-HTTP mapping within the rates module rather than a global handler. This makes the error handling is explict and maintainable

## Production metrics
- Cache hit rate
- API calls/day
- Batch sizes
- Error rate


## Assumptions and Simplifications

- **Single pod**: The semaphore and in-memory cache are JVM-local. For multiple pods, replace `InMemoryCacheClient` with Redis and use distributed locks (`SETNX`). Redis Lock can be used for distributed lock for thundering herd problem
- **Cache TTL = 300 seconds (5 minutes)**: Matches the requirement that rates should be no older than 5 minutes.
- **Expired keys are included in `keys`**: So they get re-fetched on the next batch refresh rather than being lost.
- **Seed currencies**: The 10 currencies (CNY, AUD, CAD, CHF, EUR, GBP, NZD, JPY, SGD, USD) are pre-fetched on the first request. Additional pairs are added to the cache dynamically when requested.
This list could be a ENV config in production.

## How to Run

### Prerequisites

- JDK 17
- sbt
- Docker

### 1. Start the One-Frame API

```bash
docker run -p 8080:8080 paidyinc/one-frame
```

### 2. Start the proxy

```bash
sbt run
```

The proxy starts on `http://localhost:8081`.

### 3. Query a rate

```bash
curl 'http://localhost:8081/rates?from=USD&to=JPY'
```

Response:
```json
{
"from": "USD",
"to": "JPY",
"price": 0.123456,
"timestamp": "2026-02-12T02:47:29.605Z"
}
```
1 change: 1 addition & 0 deletions forex-mtl/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ libraryDependencies ++= Seq(
Libraries.fs2,
Libraries.http4sDsl,
Libraries.http4sServer,
Libraries.http4sClient,
Libraries.http4sCirce,
Libraries.circeCore,
Libraries.circeGeneric,
Expand Down
1 change: 1 addition & 0 deletions forex-mtl/project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object Dependencies {

lazy val http4sDsl = http4s("http4s-dsl")
lazy val http4sServer = http4s("http4s-blaze-server")
lazy val http4sClient = http4s("http4s-blaze-client")
lazy val http4sCirce = http4s("http4s-circe")
lazy val circeCore = circe("circe-core")
lazy val circeGeneric = circe("circe-generic")
Expand Down
7 changes: 6 additions & 1 deletion forex-mtl/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
app {
http {
host = "0.0.0.0"
port = 8080
port = 8081
timeout = 40 seconds
}
one-frame {
host = "localhost"
port = 8080
token = "10dc303535874aeccc86a8251e6992f5"
}
}

11 changes: 9 additions & 2 deletions forex-mtl/src/main/scala/forex/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package forex
import scala.concurrent.ExecutionContext
import cats.effect._
import forex.config._
import forex.services.RatesServices
import forex.services.cache.{ InMemoryCacheClient, RatesCache }
import fs2.Stream
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.blaze.server.BlazeServerBuilder

object Main extends IOApp {
Expand All @@ -17,8 +20,12 @@ class Application[F[_]: ConcurrentEffect: Timer] {

def stream(ec: ExecutionContext): Stream[F, Unit] =
for {
config <- Config.stream("app")
module = new Module[F](config)
config <- Config.stream("app")
client <- BlazeClientBuilder[F](ec).stream
cacheClient <- Stream.eval(InMemoryCacheClient[F])
ratesCache = RatesCache[F](cacheClient)
ratesService = RatesServices.live[F](client, config.oneFrame)
module <- Stream.eval(Module[F](config, ratesService, ratesCache))
_ <- BlazeServerBuilder[F](ec)
.bindHttp(config.http.port, config.http.host)
.withHttpApp(module.httpApp)
Expand Down
24 changes: 18 additions & 6 deletions forex-mtl/src/main/scala/forex/Module.scala
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
package forex

import cats.effect.{ Concurrent, Timer }
import cats.syntax.functor._
import forex.config.ApplicationConfig
import forex.http.rates.RatesHttpRoutes
import forex.services._
import forex.programs._
import forex.services._
import forex.services.cache.RatesCache
import org.http4s._
import org.http4s.implicits._
import org.http4s.server.middleware.{ AutoSlash, Timeout }

class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {

private val ratesService: RatesService[F] = RatesServices.dummy[F]

private val ratesProgram: RatesProgram[F] = RatesProgram[F](ratesService)
class Module[F[_]: Concurrent: Timer] private (
config: ApplicationConfig,
ratesProgram: RatesProgram[F]
) {

private val ratesHttpRoutes: HttpRoutes[F] = new RatesHttpRoutes[F](ratesProgram).routes

Expand All @@ -35,3 +36,14 @@ class Module[F[_]: Concurrent: Timer](config: ApplicationConfig) {
val httpApp: HttpApp[F] = appMiddleware(routesMiddleware(http).orNotFound)

}

object Module {
def apply[F[_]: Concurrent: Timer](
config: ApplicationConfig,
ratesService: RatesService[F],
ratesCache: RatesCache[F]
): F[Module[F]] =
for {
ratesProgram <- RatesProgram[F](ratesService, ratesCache)
} yield new Module[F](config, ratesProgram)
}
Loading