Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Food Analyzer

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.

⚠️ Disclaimer

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.

About the Project

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.

Key Features

  • 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 Selector on 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 Logger writes 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.

Tech Stack

  • Language: Java 25
  • Networking: java.nio.channels (Selector, ServerSocketChannel, SocketChannel, ByteBuffer) and java.net.http (HttpClient)
  • Concurrency: ExecutorService, ConcurrentLinkedQueue, ReentrantReadWriteLock
  • JSON: Google Gson with custom adapters for Instant and for the weird USDA nutrient format
  • Testing: JUnit 5, Mockito

Architecture Overview

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
Loading

1. Accepting connections

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.

2. Reading requests

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.

3. Processing requests

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.

4. Writing responses

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.

5. Data retrieval

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 fdcIdFood 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.

6. Shutdown sequence

stop on the server console or EOF on stdin triggers the following shutdown sequence:

  1. Stop the acceptor and wake its selector, so no new connections are taken.
  2. Close the acceptor selector and its server channel.
  3. Stop the I/O workers, each of which closes its own selector and client channels.
  4. Shut down the request pool with a grace period, so in-flight requests can finish.
  5. Shut down the HttpClient.
  6. Compact all three cache files.
  7. Stop the console handler.
  8. Flush and close the logger.

Configuration

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.

Client commands

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.

Server console commands

Command Description
stop Runs the graceful shutdown sequence described above.

Project Layout

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

About

A multi-threaded, non-blocking server built with Java NIO. Implements a scalable event-loop architecture to manage concurrent connections, offloading API requests (USDA FoodData REST API) to a dynamic worker pool.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages