A multi-threaded, non-blocking TCP server written in raw Java NIO (no Spring or Spring-like framework of any kind). It sits between its own clients and the USDA FoodData Central API and answers food search, nutrition report and barcode lookup requests from a two-level cache, falling back to the USDA API only when it has to.
This repository contains only the architectural description of the project. Food Analyzer was my final project for the MJT 2024/2025 course. To respect academic integrity and the work of the MJT team, the source code is stored in a private repository.
The point of the project was to build it thing without a framework and understand how selectors, socket channels, byte buffers, thread pools, locks and cache invalidation work.
The server started as a single-threaded Selector loop that was responsible for accepting new connections, writing from and to the buffer, requesting data from the API, logging, caching in memory and on the disk. I wanted to expand it and make it better so now it has a dedicated acceptor thread, a pool of I/O workers, and a large pool of request workers that do the slow tasks.
- One selector per I/O thread - a dedicated acceptor thread hands new connections to four I/O workers by round-robin, and each worker runs its own
Selectoron its own thread. - Non-blocking I/O, blocking work isolated - the I/O threads only read bytes, hand off complete requests, and write responses back. Everything that can block (USDA API calls, JSON serialization, cache file writes) runs on a separate request-worker pool.
- Two-level cache with TTL - an in-memory index for fast lookups, backed by append-only JSON files on disk so the cache survives restarts.
- Cache compaction - the disk cache is written append-only at runtime, then rewritten into a compacted file on shutdown, dropping expired and duplicated entries.
- Fine-grained locking - each cache storage has its own
ReentrantReadWriteLock, so lookups of foods, queries and barcodes don't contend with one another and concurrent readers don't block each other. - Asynchronous logging - a custom
Loggerwrites to the console and offloads file writes to a single-threaded executor, so no worker thread ever waits on disk to log. - Unit tested - JUnit 5 and Mockito, including mocked file systems and HTTP responses.
- Language: Java 25
- Networking:
java.nio.channels(Selector,ServerSocketChannel,SocketChannel,ByteBuffer) andjava.net.http(HttpClient) - Concurrency:
ExecutorService,ConcurrentLinkedQueue,ReentrantReadWriteLock - JSON: Google Gson with custom adapters for
Instantand for the weird USDA nutrient format - Testing: JUnit 5, Mockito
flowchart LR
C[Clients] -->|TCP| A[Acceptor thread<br/>OP_ACCEPT selector]
A -->|round-robin<br/>hand-off| W1[IOWorker 1<br/>own selector]
A --> W2[IOWorker 2<br/>own selector]
A --> W3[IOWorker 3<br/>own selector]
A --> W4[IOWorker 4<br/>own selector]
W1 --> RP[Request worker pool]
W2 --> RP
W3 --> RP
W4 --> RP
RP --> R[FoodDataRepository]
R -->|hit| CA[(Two-level cache<br/>memory + disk)]
R -->|miss| API[USDA FoodData Central]
API --> CA
RP -.->|response queue + selector wakeup<br/>back to the owning IOWorker| W1
A single acceptor thread owns a Selector registered for OP_ACCEPT. When a connection arrives it configures the channel as non-blocking and hands it to the next I/O worker using round-robin scheduling.
Each IOWorker runs its own Selector on its own thread and owns a set of client channels. The acceptor pushes the accepted channel onto a ConcurrentLinkedQueue and calls selector.wakeup(), and the worker registers it for OP_READ on its next pass.
Every channel carries an attachment holding its own ByteBuffer and its pending response, so partial reads are kept per connection. Requests are delimited by \0.
A complete request becomes a ClientRequestWorker task submitted to the request-worker pool. That task parses the command, resolves it through the repository and produces a JSON response. Meanwhile the IOWorker goes back to its selector loop so a slow USDA API call blocks one pooled request-worker thread and not one of the four IOWorkers.
When the request worker is done it pushes the response onto the I/O worker's response queue and wakes its selector. The I/O worker attaches the response to the client's key, switches its interest to OP_WRITE, writes the buffer out, and switches back to OP_READ once the buffer is drained. Only the owning I/O worker ever touches a channel.
FoodDataRepository composes two implementations of the Requester interface: CacheRequester and HttpRequester. It checks the in-memory cache first, and on a cache miss calls the USDA API and writes the result into both cache layers.
The cache is split into three storages:
| Storage | Key → Value | Notes |
|---|---|---|
FoodStorage |
fdcId → Food |
The only place full food objects live. Entries carry an expiry timestamp. |
QueryStorage |
search query → list of fdcId |
Stores only ids, so cached search results always resolve to the current food entries. |
BarcodeStorage |
GTIN/UPC → fdcId |
Populated automatically whenever a food with a barcode is cached. |
Each storage keeps a HashMap in memory and a line-delimited JSON file in cache/. Writes append a single line. On shutdown the file is compacted and on startup it is replayed, dropping expired and duplicated entries in both directions.
stop on the server console or EOF on stdin triggers the following shutdown sequence:
- Stop the acceptor and wake its selector, so no new connections are taken.
- Close the acceptor selector and its server channel.
- Stop the I/O workers, each of which closes its own selector and client channels.
- Shut down the request pool with a grace period, so in-flight requests can finish.
- Shut down the
HttpClient. - Compact all three cache files.
- Stop the console handler.
- Flush and close the logger.
The API key is read from the FOOD_API_KEY environment variable.
The server binds to localhost:7777. Pool sizes, the cache TTL and the shutdown timeouts are private static final variables in Server.java.
| Command | Description | Example |
|---|---|---|
get-food <query> |
Search foods by keywords. All words must match (up to 5 results). | get-food pancake |
get-food-report <fdcId> |
Full nutritional report for one food, by FDC id. | get-food-report 123456 |
get-food-by-barcode --code=<gtin> |
Look up a product by its GTIN/UPC barcode. | get-food-by-barcode --code=009800800030 |
get-food-by-barcode --img=<img> |
Look up a product by an image of a GTIN/UPC barcode. | get-food-by-barcode --img=barcode.jpg |
Barcode lookups are answered from the cache only as the USDA API has no barcode endpoint, so a barcode resolves only if that product was already fetched by an earlier search or report.
| Command | Description |
|---|---|
stop |
Runs the graceful shutdown sequence described above. |
src/
├── Main.java
└── bg/sofia/uni/fmi/mjt/
├── api/
│ ├── parameter/
│ │ ├── ApiKey.java
│ │ ├── Keywords.java
│ │ ├── PageNumber.java
│ │ ├── PageSize.java
│ │ ├── Parameter.java
│ │ └── RequireAllWords.java
│ ├── request/
│ │ ├── Query.java
│ │ └── RequestParameters.java
│ ├── requester/
│ │ ├── CacheRequester.java
│ │ ├── HttpRequester.java
│ │ └── Requester.java
│ └── response/
│ ├── APIResponse.java
│ ├── Food.java
│ └── Nutrient.java
├── exception/
│ ├── ApiKeyNotFoundException.java
│ ├── ClientDisconnectedException.java
│ ├── CorruptedCacheException.java
│ └── RequesterException.java
├── json/
│ ├── InstantAdapter.java
│ └── NutrientDeserializer.java
├── server/
│ ├── Server.java
│ ├── cache/
│ │ ├── Cache.java
│ │ ├── entry/
│ │ │ ├── BarcodeCacheEntry.java
│ │ │ ├── CacheEntry.java
│ │ │ ├── FoodCacheEntry.java
│ │ │ └── QueryCacheEntry.java
│ │ └── storage/
│ │ ├── AbstractStorage.java
│ │ ├── BarcodeStorage.java
│ │ ├── CacheStorage.java
│ │ ├── FoodStorage.java
│ │ └── QueryStorage.java
│ ├── command/
│ │ ├── Command.java
│ │ ├── InvalidCommand.java
│ │ ├── Shutdown.java
│ │ └── parser/
│ │ └── CommandParser.java
│ ├── logger/
│ │ └── Logger.java
│ ├── repository/
│ │ ├── FoodDataRepository.java
│ │ └── FoodRepository.java
│ ├── request/
│ │ ├── GetFood.java
│ │ ├── GetFoodByBarcode.java
│ │ ├── GetFoodReport.java
│ │ ├── InvalidRequest.java
│ │ ├── Request.java
│ │ ├── parser/
│ │ │ └── RequestParser.java
│ │ └── response/
│ │ └── PendingResponse.java
│ └── thread/
│ ├── AcceptorWorker.java
│ ├── Attachment.java
│ ├── ClientRequestWorker.java
│ ├── IOWorker.java
│ └── ServerInputHandler.java
└── util/
└── NioUtils.java