Repository: ndsev/mapget (language: python)

Issue:
Persistent Cache is available
### Background:
The existing in-memory cache in mapget serves its purpose for short-lived sessions but poses limitations for long-term or recurring data retrieval needs. This transient nature of the cache can lead to repetitive data fetching from source, negatively impacting performance and user experience. Implementing a persistent cache would alleviate this issue by storing data between sessions, saving time and computational resources. 

### Goal:
Introduce a persistent caching mechanism in mapget to improve long-term data retrieval efficiency and to complement the existing in-memory cache.

### Acceptance Criteria:

1. **Persistence:** The cache should be capable of storing data between different sessions and system reboots.
  
2. **Compatibility:** The persistent cache should function alongside the existing in-memory cache, allowing users the flexibility to choose between the two depending on their needs.

4. **Cache Flushing:** Users should have the ability to manually clear the persistent cache when needed.
  
6. **Future-Proofing:** Like the in-memory cache, the implementation should be designed to be compatible with other caching mechanisms that may be introduced later.

7.  **Cache configuration**: Users can specify the max number of tiles, path to cache-file and the type of used cache (memory, persistent)

8. **Cache statistics**: The persitent cache tracks number of hits and misses. It's sufficient to get this information via logs.
  


Discussion / hints:
**Regarding the Time-To-Live:** There is an open TODO here: https://github.com/Klebert-Engineering/mapget/blob/72f050dec2af03f97031a5fe0724d636ed719309/libs/service/src/service.cpp#L139

When a cached tile is served, we need to check whether tile.getTimestamp() + tile.getTtl() are less than the current time).

**Regarding cache size:** We need several config parameters to configure...

* The used cache implementation (e.g. `--cache-type` `memory` or `db`)
* The maximum cache size (e.g. `--cache-max-tiles`)
* The cache db path (`--cache-path`?). This will be ignored for the mem-cache.
for `--cache-max-tiles` support also unlimited number of tiles 

Fix patch:
diff --git a/.github/workflows/cmake.yaml b/.github/workflows/cmake.yaml
index 85ef5a8c..0945fcca 100644
--- a/.github/workflows/cmake.yaml
+++ b/.github/workflows/cmake.yaml
@@ -43,7 +43,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [macos-latest, windows-latest]
+        os: [macos-latest, windows-2019]
         python-version: ["3.8", "3.9", "3.10", "3.11"]
     steps:
       - uses: actions/checkout@v2
@@ -73,8 +73,10 @@ jobs:
                 "$(pwd)"/bin/wheel-auditme/mapget*.whl \
                 "$(pwd)"/bin/wheel mapget
       - name: Build (Windows)
-        if: matrix.os == 'windows-latest'
+        if: matrix.os == 'windows-latest' || matrix.os == 'windows-2019'
         working-directory: build
+        env:
+          CMAKE_GENERATOR: "Visual Studio 16 2019"
         run: |
           choco install --no-progress -y openssl --version=1.1.1.2100
           echo "cmake -DPython3_ROOT_DIR=$env:pythonLocation"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0b86e77b..49640816 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -168,6 +168,23 @@ add_subdirectory(libs/logging)
 add_subdirectory(libs/model)
 
 if (MAPGET_WITH_SERVICE OR MAPGET_WITH_HTTPLIB OR MAPGET_ENABLE_TESTING)
+  if (NOT TARGET rocksdb)
+    # RocksDB adds ASM as language to the project, upon which CodeCoverage.cmake
+    # tries to find a matching compiler and fails. So RocksDB must be included
+    # after CodeCoverage.
+    set(WITH_GFLAGS NO CACHE BOOL "rocksdb without gflags")
+    set(WITH_TESTS NO CACHE BOOL "rocksdb without tests")
+    set(WITH_BENCHMARK_TOOLS NO CACHE BOOL "rocksdb without benchmarking")
+    set(BENCHMARK_ENABLE_GTEST_TESTS NO CACHE BOOL "rocksdb without gtest")
+    set(DISABLE_WARNING_AS_ERROR 1 CACHE BOOL "rocksdb warnings are ok")
+
+    FetchContent_Declare(rocksdb
+      GIT_REPOSITORY "https://github.com/facebook/rocksdb.git"
+      GIT_TAG        dc87847e65449ef1cb6f787c5d753cbe8562bff1 # Use version greater than v8.6.7 once released.
+      GIT_SHALLOW    OFF)  # Turn ON when using released tag.
+    FetchContent_MakeAvailable(rocksdb)
+  endif()
+
   add_subdirectory(libs/service)
 endif()
 
diff --git a/README.md b/README.md
index 61cf0daf..0340d04e 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
 **Main Capabilities:**
 
 * Coordinating requests for map data to various map data source processes.
-* Integrated map data cache based on sqlite or RocksDB (*TODO: Evaluate best cache DB solution*), or a simple memory cache.
+* Integrated map data cache based on RocksDB, or a simple in-memory cache.
 * Simple data-source API with bindings for C++, Python and JS.
 * Compact GeoJSON feature storage model - [25 to 50% smaller than BSON/msgpack](docs/size-comparison/table.md).
 * Integrated deep [feature filter language](https://github.com/klebert-engineering/simfil) based on (a subset of) *JSONata*
@@ -40,6 +40,18 @@ Sample configuration files can be found under `examples/config`:
 - [sample-first-datasource.toml](examples/config/sample-first-datasource.toml) and [sample-second-datasource.toml](examples/config/sample-second-datasource.toml) will configure mapget to run a simple datasource with sample data. Note: the two formats in config files for subcommand parameters can be used interchangeably.
 - [sample-service.toml](examples/config/sample-service.toml) to execute the `mapget serve` command. The instance will fetch and serve data from sources started with `sample-*-datasource.toml` configs above.
 
+### Cache
+
+`mapget` supports persistent tile caching using a RocksDB-backed cache, and non-persistent
+in-memory caching. The CLI options to configure caching behavior are:
+
+| Option                   | Description                                                                                          | Default Value   |
+|--------------------------|------------------------------------------------------------------------------------------------------|-----------------|
+| `-c,--cache-type`        | Choose between "rocksdb" or "memory".                                                                | rocksdb         |
+| `--cache-dir`            | Path to store RocksDB cache.                                                                         | mapget-cache    |
+| `--cache-max-tiles`      | Number of tiles to store. Tiles are purged from cache in FIFO order. Set to 0 for unlimited storage. | 1024            |
+| `--clear-cache`          | Clear existing cache entries at startup.                                                             | false           |
+
 ## Map Data Sources
 
 At the heart of *mapget* are data sources, which provide map feature data for
diff --git a/docs/components.png b/docs/components.png
index 4ad18e72..c9acee03 100644
Binary files a/docs/components.png and b/docs/components.png differ
diff --git a/examples/config/sample-service.toml b/examples/config/sample-service.toml
index 94d45f5a..85b1e3ec 100644
--- a/examples/config/sample-service.toml
+++ b/examples/config/sample-service.toml
@@ -9,6 +9,9 @@ serve.port=61852
 # Datasources for the server in format <host:port>. Can be specified multiple times.
 serve.datasource-host=["127.0.0.1:61853", "127.0.0.1:61854"]
 
+# Using a persistent cache.
+serve.cache-type = "rocksdb"
+
 # Datasource executable paths, including arguments, for the server. Can be specified multiple times.
 # serve.datasource-exe=
 
diff --git a/libs/http-service/src/cli.cpp b/libs/http-service/src/cli.cpp
index f554ba6c..a0ba85a1 100644
--- a/libs/http-service/src/cli.cpp
+++ b/libs/http-service/src/cli.cpp
@@ -1,14 +1,14 @@
 #include "cli.h"
-#include "http-service.h"
 #include "http-client.h"
+#include "http-service.h"
 #include "mapget/log.h"
 
 #include "mapget/http-datasource/datasource-client.h"
+#include "mapget/service/rocksdbcache.h"
 
 #include <CLI/CLI.hpp>
-#include <vector>
 #include <string>
-#include <iostream>
+#include <vector>
 
 namespace mapget
 {
@@ -18,56 +18,101 @@ struct ServeCommand
     int port_ = 0;
     std::vector<std::string> datasourceHosts_;
     std::vector<std::string> datasourceExecutables_;
+    std::string cacheType_;
+    std::string cachePath_;
+    int64_t cacheMaxTiles_ = 1024;
+    bool clearCache_ = false;
     std::string webapp_;
 
-    explicit ServeCommand(CLI::App& app) {
+    explicit ServeCommand(CLI::App& app)
+    {
         auto serveCmd = app.add_subcommand("serve", "Starts the server.");
-        serveCmd->add_option("-p,--port", port_, "Port to start the server on. Default is 0.")->default_val("0");
-        serveCmd->add_option("-d,--datasource-host", datasourceHosts_, "Datasources for the server in format <host:port>. Can be specified multiple times.");
-        serveCmd->add_option("-e,--datasource-exe", datasourceExecutables_, "Datasource executable paths, including arguments, for the server. Can be specified multiple times.");
-        serveCmd->add_option("-w,--webapp", webapp_, "Serve a static web application, in the format [<url-scope>:]<filesystem-path>.");
+        serveCmd->add_option(
+            "-p,--port",
+            port_,
+            "Port to start the server on. Default is 0.")
+            ->default_val("0");
+        serveCmd->add_option(
+            "-d,--datasource-host",
+            datasourceHosts_,
+            "Data sources in format <host:port>. Can be specified multiple times.");
+        serveCmd->add_option(
+            "-e,--datasource-exe",
+            datasourceExecutables_,
+            "Data source executable paths, including arguments. "
+            "Can be specified multiple times.");
+        serveCmd->add_option(
+            "-c,--cache-type", cacheType_, "From [memory|rocksdb], default rocksdb.")
+            ->default_val("rocksdb");
+        serveCmd->add_option(
+            "--cache-dir", cachePath_, "Path to store RocksDB cache.")
+            ->default_val("mapget-cache");
+        serveCmd->add_option(
+            "--cache-max-tiles", cacheMaxTiles_, "0 for unlimited, default 1024.")
+            ->default_val(1024);
+        serveCmd->add_option(
+            "--clear-cache", clearCache_, "Clear existing cache at startup.")
+            ->default_val(false);
+        serveCmd->add_option(
+            "-w,--webapp",
+            webapp_,
+            "Serve a static web application, in the format [<url-scope>:]<filesystem-path>.");
         serveCmd->callback([this]() { serve(); });
     }
 
     void serve()
     {
         log().info("Starting server on port {}.", port_);
-        HttpService srv;
 
-        if (!datasourceHosts_.empty()){
-            for (auto& ds : datasourceHosts_){
+        std::shared_ptr<Cache> cache;
+        if (cacheType_ == "rocksdb") {
+            cache = std::make_shared<RocksDBCache>(cacheMaxTiles_, cachePath_, clearCache_);
+        }
+        else if (cacheType_ == "memory") {
+            log().info("Initializing in-memory cache.");
+            cache = std::make_shared<MemCache>(cacheMaxTiles_);
+        }
+        else {
+            logRuntimeError(stx::format("Cache type {} not supported!", cacheType_));
+        }
+
+        HttpService srv(cache);
+
+        if (!datasourceHosts_.empty()) {
+            for (auto& ds : datasourceHosts_) {
                 auto delimiterPos = ds.find(':');
                 std::string dsHost = ds.substr(0, delimiterPos);
-                int dsPort = std::stoi(ds.substr(delimiterPos+1, ds.size()));
+                int dsPort = std::stoi(ds.substr(delimiterPos + 1, ds.size()));
                 log().info("Connecting to datasource at {}:{}.", dsHost, dsPort);
                 try {
                     srv.add(std::make_shared<RemoteDataSource>(dsHost, dsPort));
                 }
-                catch(std::exception const& e) {
+                catch (std::exception const& e) {
                     log().error("  ...failed: {}", e.what());
                 }
             }
         }
 
-        if (!datasourceExecutables_.empty()){
-            for (auto& ds : datasourceExecutables_){
+        if (!datasourceExecutables_.empty()) {
+            for (auto& ds : datasourceExecutables_) {
                 log().info("Launching datasource exe: {}", ds);
                 try {
                     srv.add(std::make_shared<RemoteDataSourceProcess>(ds));
                 }
-                catch(std::exception const& e) {
+                catch (std::exception const& e) {
                     log().error("  ...failed: {}", e.what());
                 }
             }
         }
 
-        if (!webapp_.empty()){
+        if (!webapp_.empty()) {
             log().info("Webapp: {}", webapp_);
-            if (!srv.mountFileSystem(webapp_)){
+            if (!srv.mountFileSystem(webapp_)) {
                 log().error("  ...failed to mount!");
                 exit(1);
             }
         }
+
         srv.go("0.0.0.0", port_);
         srv.waitForSignal();
     }
@@ -78,16 +123,24 @@ struct FetchCommand
     std::string server_, map_, layer_;
     std::vector<uint64_t> tiles_;
 
-    explicit FetchCommand(CLI::App& app) {
+    explicit FetchCommand(CLI::App& app)
+    {
         auto fetchCmd = app.add_subcommand("fetch", "Connects to the server to fetch tiles.");
-        fetchCmd->add_option("-s,--server", server_, "Server to connect to in format <host:port>.")->required();
+        fetchCmd->add_option("-s,--server", server_, "Server to connect to in format <host:port>.")
+            ->required();
         fetchCmd->add_option("-m,--map", map_, "Map to retrieve.")->required();
         fetchCmd->add_option("-l,--layer", layer_, "Layer of the map to retrieve.")->required();
-        fetchCmd->add_option("-t,--tile", tiles_, "Tile of the map to retrieve. Can be specified multiple times.")->required();
+        fetchCmd
+            ->add_option(
+                "-t,--tile",
+                tiles_,
+                "Tile of the map to retrieve. Can be specified multiple times.")
+            ->required();
         fetchCmd->callback([this]() { fetch(); });
     }
 
-    void fetch() {
+    void fetch()
+    {
         if (log().level() <= spdlog::level::debug) {
             // Skips building the tile list string if it will not be logged.
             std::string tileList = "";
@@ -104,12 +157,15 @@ struct FetchCommand
 
         auto delimiterPos = server_.find(':');
         std::string host = server_.substr(0, delimiterPos);
-        int port = std::stoi(server_.substr(delimiterPos+1, server_.size()));
+        int port = std::stoi(server_.substr(delimiterPos + 1, server_.size()));
 
         mapget::HttpClient cli(host, port);
         auto request = std::make_shared<LayerTilesRequest>(
-            map_, layer_, std::vector<TileId>{tiles_.begin(), tiles_.end()},
-            [](auto&& tile){
+            map_,
+            layer_,
+            std::vector<TileId>{tiles_.begin(), tiles_.end()},
+            [](auto&& tile)
+            {
                 if (log().level() == spdlog::level::trace) {
                     log().trace(tile->toGeoJson().dump());
                 }
@@ -123,11 +179,14 @@ int runFromCommandLine(std::vector<std::string> args)
     CLI::App app{"A client/server application for map data retrieval."};
     std::string log_level_;
     app.add_option(
-        "--log-level",
-        log_level_,
-        "From: trace, debug, info, warn, error, critical. Overrides MAPGET_LOG_LEVEL."
-    )->default_val("");
-    app.set_config("--config", "", "Optional path to a file with configuration arguments for mapget.");
+           "--log-level",
+           log_level_,
+           "From [trace|debug|info|warn|error|critical], overrides MAPGET_LOG_LEVEL.")
+        ->default_val("");
+    app.set_config(
+        "--config",
+        "",
+        "Optional path to a file with configuration arguments for mapget.");
 
     app.require_subcommand(1);
 
@@ -141,10 +200,11 @@ int runFromCommandLine(std::vector<std::string> args)
     try {
         std::reverse(args.begin(), args.end());
         app.parse(std::move(args));
-    } catch(const CLI::ParseError &e) {
+    }
+    catch (const CLI::ParseError& e) {
         return app.exit(e);
     }
     return 0;
 }
 
-}
+}  // namespace mapget
diff --git a/libs/model/include/mapget/model/featurelayer.h b/libs/model/include/mapget/model/featurelayer.h
index 8442f337..058308f7 100644
--- a/libs/model/include/mapget/model/featurelayer.h
+++ b/libs/model/include/mapget/model/featurelayer.h
@@ -49,8 +49,7 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool
      * @param layerInfo Information about the map layer this feature is associated with.
      *  Each feature in this layer must have a feature type which is also present in
      *  the layer. Therefore, feature ids from this layer can be verified to conform
-     *  to one of the allowed feature id compositions for the allowed type.
-     * TODO "for the allowed type" -> "for the feature type" ?
+     *  to one of the allowed feature id compositions for the feature type.
      * @param fields Shared field name dictionary, which allows compressed storage
      *  of object field name strings. It is auto-filled, and one instance may be used
      *  by multiple TileFeatureLayer instances.
@@ -218,7 +217,7 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool
     model_ptr<FeatureId> resolveFeatureId(simfil::ModelNode const& n) const;
 
     /**
-     * Generic node resolution overload
+     * Generic node resolution overload.
      */
     void resolve(const simfil::ModelNode &n, const ResolveFn &cb) const override;
 
diff --git a/libs/model/include/mapget/model/fields.h b/libs/model/include/mapget/model/fields.h
index eb584687..fe9e91c2 100644
--- a/libs/model/include/mapget/model/fields.h
+++ b/libs/model/include/mapget/model/fields.h
@@ -6,17 +6,15 @@ namespace mapget
 {
 
 /**
- * The Fields class is a case-insensitive dictionary for field names.
- * It acts as a container that automatically populates itself when fields are added to an object.
- * Multiple TileFeatureLayers can share the same Fields dictionary, which helps reduce the size
- * of serialized FeatureLayers.
+ * The Fields class is a case-insensitive dictionary of uint16_t to field name strings.
+ * It populates itself when fields are added to an object. Multiple TileFeatureLayers
+ * can share the same Fields dictionary, reducing the size of serialized FeatureLayers.
  *
- * The inherited mapget::Fields provides additional static field IDs that can be used for various purposes.
+ * The inherited mapget::Fields contains static field IDs for various purposes.
  *
- * Field IDs managed by the Fields dictionary are stored as uint16 values, which are smaller and faster
- * to work with compared to strings. When querying fields, the Fields dictionary is used to look up the
- * field ID for a given name. Once the ID is obtained, subsequent searches are performed using this 16-bit
- * integer, resulting in improved efficiency.
+ * Field IDs are uint16 values, which are smaller and faster to work with than strings.
+ * When querying fields by name, the Fields dictionary is used to look up a field ID.
+ * Subsequent searches are performed using this 16-bit integer for improved efficiency.
  *
  * Note: A fields dictionary is always unique per datasource node. Therefore,
  * the Fields object must be constructed with a datasource node id.
@@ -33,7 +31,7 @@ struct Fields : public simfil::Fields
         PropertiesStr,
     };
 
-    explicit Fields(std::string nodeId);
+    explicit Fields(const std::string_view& nodeId);
 
     /**
      * Write is overloaded, because it prepends the stream with
diff --git a/libs/model/include/mapget/model/stream.h b/libs/model/include/mapget/model/stream.h
index a193806f..d69ebfee 100644
--- a/libs/model/include/mapget/model/stream.h
+++ b/libs/model/include/mapget/model/stream.h
@@ -49,7 +49,7 @@ class TileLayerStream
         Reader(
             LayerInfoResolveFun layerInfoProvider,
             std::function<void(TileFeatureLayer::Ptr)> onParsedLayer,
-            std::shared_ptr<CachedFieldsProvider> fieldCacheProvider = nullptr);
+            std::shared_ptr<CachedFieldsProvider> cachedFieldsProvider = nullptr);
 
         /**
          * Add some bytes to parse. The next object will be parsed once
@@ -63,6 +63,13 @@ class TileLayerStream
         /** Obtain the fields dict provider used by this Reader. */
         std::shared_ptr<CachedFieldsProvider> fieldDictCache();
 
+        /**
+         * Read a message header from a stream. Returns true and the next message's type and
+         * size, or false, if no sufficient bytes are available. Throws if the protocol version
+         * in the header does not match the version currently used by mapget.
+         */
+        static bool readMessageHeader(std::stringstream& stream, MessageType& outType, uint32_t& outSize);
+
     private:
         enum class Phase { ReadHeader, ReadValue };
 
@@ -70,11 +77,15 @@ class TileLayerStream
         MessageType nextValueType_ = MessageType::None;
         uint32_t nextValueSize_ = 0;
 
+        /**
+         * Reads messages from the current available data.
+         * @return True if it can continue and should be called again, false otherwise.
+         */
         bool continueReading();
 
         std::stringstream buffer_;
         LayerInfoResolveFun layerInfoProvider_;
-        std::shared_ptr<CachedFieldsProvider> fieldCacheProvider_;
+        std::shared_ptr<CachedFieldsProvider> cachedFieldsProvider_;
         std::function<void(TileFeatureLayer::Ptr)> onParsedLayer_;
     };
 
@@ -91,10 +102,15 @@ class TileLayerStream
          * dictionary will be updated as Fields dictionary updates are sent.
          * Using the same FieldId offset map for two Writer objects will
          * lead to undefined behavior.
+         *
+         * Setting differentialFieldUpdates=false is necessary when using
+         * the Writer with a Cache database, because it is not desirable
+         * to store partial Field dicts in the database.
          */
         Writer(
             std::function<void(std::string, MessageType)> onMessage,
-            FieldOffsetMap& fieldsOffsets);
+            FieldOffsetMap& fieldsOffsets,
+            bool differentialFieldUpdates = true);
 
         /** Serialize a tile feature layer and the required part of a Fields cache. */
         void write(TileFeatureLayer::Ptr const& tileFeatureLayer);
@@ -104,15 +120,16 @@ class TileLayerStream
 
         std::function<void(std::string, MessageType)> onMessage_;
         FieldOffsetMap& fieldsOffsets_;
+        bool differentialFieldUpdates_ = true;
     };
 
     /**
-     * Cache for Fields-dictionaries. Fields dictionaries are unique per data source node,
+     * Cache for Fields-dictionaries. Fields-dicts are unique per data source node,
      * since each data source node may have a uniquely-filled field cache.
-     * The default implementation just places an empty Fields-dictionary
-     * into the cache if there is no registered one for the given node id.
+     * The default implementation just places an empty Fields-dict into the cache
+     * if there is no registered one for the given node id.
      * Derived CachedFieldsProviders may handle uncached Fields dicts differently,
-     * e.g. by initializing the Fields-object from a cache database.
+     * e.g. by initializing the Fields object from a cache database.
      */
     struct CachedFieldsProvider
     {
@@ -123,18 +140,18 @@ class TileLayerStream
          * This operator is called by the Reader to obtain the fields
          * dictionary for a particular node id.
          */
-        virtual std::shared_ptr<Fields> operator() (std::string_view const& nodeIf);
+        virtual std::shared_ptr<Fields> operator() (std::string_view const& nodeId);
 
         /**
          * Obtain the highest known field id for each data source node id,
          * as currently present in the cache. The resulting dict may be
          * used by a mapget http client to set the `maxKnownFieldIds` info.
          */
-        virtual FieldOffsetMap fieldDictOffsets() const;
+        [[nodiscard]] virtual FieldOffsetMap fieldDictOffsets() const;
 
     protected:
         std::shared_mutex fieldCacheMutex_;
-        std::map<std::string, std::shared_ptr<Fields>> fieldsPerNodeId_;
+        std::map<std::string, std::shared_ptr<Fields>, std::less<void>> fieldsPerNodeId_;
     };
 };
 
diff --git a/libs/model/src/featurelayer.cpp b/libs/model/src/featurelayer.cpp
index 8a4fdaa1..252937ce 100644
--- a/libs/model/src/featurelayer.cpp
+++ b/libs/model/src/featurelayer.cpp
@@ -64,8 +64,8 @@ TileFeatureLayer::TileFeatureLayer(
 
 TileFeatureLayer::TileFeatureLayer(
     std::istream& inputStream,
-    const LayerInfoResolveFun& layerInfoResolveFun,
-    const FieldNameResolveFun& fieldNameResolveFun
+    LayerInfoResolveFun const& layerInfoResolveFun,
+    FieldNameResolveFun const& fieldNameResolveFun
 ) :
     TileLayer(inputStream, layerInfoResolveFun),
     simfil::ModelPool(fieldNameResolveFun(nodeId_)),
@@ -242,6 +242,7 @@ simfil::shared_model_ptr<Feature> TileFeatureLayer::newFeature(
     // contains only references to feature nodes, in the order
     // of the feature node column.
     addRoot(simfil::ModelNode::Ptr(result));
+    setInfo("num-features", numRoots());
     return result;
 }
 
diff --git a/libs/model/src/fields.cpp b/libs/model/src/fields.cpp
index 52c294da..e90b408f 100644
--- a/libs/model/src/fields.cpp
+++ b/libs/model/src/fields.cpp
@@ -7,7 +7,7 @@
 namespace mapget
 {
 
-Fields::Fields(std::string nodeId) : nodeId_(std::move(nodeId)) {
+Fields::Fields(const std::string_view& nodeId) : nodeId_(nodeId) {
     addStaticKey(IdStr, "id");
     addStaticKey(TypeIdStr, "typeId");
     addStaticKey(LayerStr, "layer");
diff --git a/libs/model/src/stream.cpp b/libs/model/src/stream.cpp
index fd2ff6f8..300ae50e 100644
--- a/libs/model/src/stream.cpp
+++ b/libs/model/src/stream.cpp
@@ -13,7 +13,7 @@ TileLayerStream::Reader::Reader(
     std::function<void(TileFeatureLayer::Ptr)> onParsedLayer,
     std::shared_ptr<CachedFieldsProvider> fieldCacheProvider)
     : layerInfoProvider_(std::move(layerInfoProvider)),
-      fieldCacheProvider_(
+      cachedFieldsProvider_(
           fieldCacheProvider ? std::move(fieldCacheProvider) :
                                std::make_shared<TileLayerStream::CachedFieldsProvider>()),
       onParsedLayer_(std::move(onParsedLayer))
@@ -33,30 +33,18 @@ bool TileLayerStream::Reader::eos()
 
 bool TileLayerStream::Reader::continueReading()
 {
-    bitsery::Deserializer<bitsery::InputStreamAdapter> s(buffer_);
-    auto numUnreadBytes = buffer_.tellp() - buffer_.tellg();
-
     if (currentPhase_ == Phase::ReadHeader)
     {
-        // Version: 6B, Type: 1B, Size: 4B
-        constexpr auto headerSize = 6 + 1 + 4;
-        if (numUnreadBytes < headerSize)
+        if (readMessageHeader(buffer_, nextValueType_, nextValueSize_)) {
+            currentPhase_ = Phase::ReadValue;
+        }
+        else {
             return false;
-
-        Version protocolVersion;
-        s.object(protocolVersion);
-        if (!protocolVersion.isCompatible(CurrentProtocolVersion)) {
-            throw logRuntimeError(stx::format(
-                "Unable to read message with version {} using version {}.",
-                protocolVersion.toString(),
-                CurrentProtocolVersion.toString()));
         }
-        s.value1b(nextValueType_);
-        s.value4b(nextValueSize_);
-        currentPhase_ = Phase::ReadValue;
-        return true;
     }
 
+    bitsery::Deserializer<bitsery::InputStreamAdapter> s(buffer_);
+    auto numUnreadBytes = buffer_.tellp() - buffer_.tellg();
     if (numUnreadBytes < nextValueSize_)
         return false;
 
@@ -66,17 +54,17 @@ bool TileLayerStream::Reader::continueReading()
         auto tileFeatureLayer = std::make_shared<TileFeatureLayer>(
             buffer_,
             layerInfoProvider_,
-            [this](auto&& nodeId){return (*fieldCacheProvider_)(nodeId);});
-        // Calculate duration
+            [this](auto&& nodeId){return (*cachedFieldsProvider_)(nodeId);});
+        // Calculate duration.
         auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now() - start);
-        log().debug("Reading {} kB took {} ms.", nextValueSize_/1000, elapsed.count());
+        log().trace("Reading {} kB took {} ms.", nextValueSize_/1000, elapsed.count());
         onParsedLayer_(tileFeatureLayer);
     }
     else if (nextValueType_ == MessageType::Fields)
     {
-        // Read the node id which identifies the fields dictionary
+        // Read the node id which identifies the fields' dictionary.
         std::string fieldsDictNodeId = Fields::readDataSourceNodeId(buffer_);
-        (*fieldCacheProvider_)(fieldsDictNodeId)->read(buffer_);
+        (*cachedFieldsProvider_)(fieldsDictNodeId)->read(buffer_);
     }
 
     currentPhase_ = Phase::ReadHeader;
@@ -85,14 +73,40 @@ bool TileLayerStream::Reader::continueReading()
 
 std::shared_ptr<TileLayerStream::CachedFieldsProvider> TileLayerStream::Reader::fieldDictCache()
 {
-    return fieldCacheProvider_;
+    return cachedFieldsProvider_;
+}
+
+bool TileLayerStream::Reader::readMessageHeader(std::stringstream & stream, MessageType& outType, uint32_t& outSize)
+{
+    bitsery::Deserializer<bitsery::InputStreamAdapter> s(stream);
+    auto numUnreadBytes = stream.tellp() - stream.tellg();
+
+    // Version: 6B, Type: 1B, Size: 4B
+    constexpr auto headerSize = 6 + 1 + 4;
+    if (numUnreadBytes < headerSize)
+        return false;
+
+    Version protocolVersion;
+    s.object(protocolVersion);
+    if (!protocolVersion.isCompatible(CurrentProtocolVersion)) {
+        throw logRuntimeError(stx::format(
+            "Unable to read message with version {} using version {}.",
+            protocolVersion.toString(),
+            CurrentProtocolVersion.toString()));
+    }
+    s.value1b(outType);
+    s.value4b(outSize);
+
+    return true;
 }
 
 TileLayerStream::Writer::Writer(
     std::function<void(std::string, MessageType)> onMessage,
-    FieldOffsetMap& fieldsOffsets)
+    FieldOffsetMap& fieldsOffsets,
+    bool differentialFieldUpdates)
     : onMessage_(std::move(onMessage)),
-      fieldsOffsets_(fieldsOffsets)
+      fieldsOffsets_(fieldsOffsets),
+      differentialFieldUpdates_(differentialFieldUpdates)
 {
 }
 
@@ -106,7 +120,10 @@ void TileLayerStream::Writer::write(TileFeatureLayer::Ptr const& tileFeatureLaye
     {
         // Need to send the client an update for the Fields dictionary
         std::stringstream serializedFields;
-        fields->write(serializedFields, highestFieldKnownToClient+1);
+        auto fieldUpdateOffset = 0;
+        if (differentialFieldUpdates_)
+            fieldUpdateOffset = highestFieldKnownToClient+1;
+        fields->write(serializedFields, fieldUpdateOffset);
         sendMessage(serializedFields.str(), MessageType::Fields);
         highestFieldKnownToClient = highestField;
     }
@@ -117,12 +134,15 @@ void TileLayerStream::Writer::write(TileFeatureLayer::Ptr const& tileFeatureLaye
     tileFeatureLayer->write(serializedFeatureLayer);
     auto bytes = serializedFeatureLayer.str();
     auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now() - start);
-    log().debug("Writing {} kB took {} ms.", bytes.size()/1000, elapsed.count());
+    log().trace("Writing {} kB took {} ms.", bytes.size()/1000, elapsed.count());
     sendMessage(std::move(bytes), MessageType::TileFeatureLayer);
 }
 
 void TileLayerStream::Writer::sendMessage(std::string&& bytes, TileLayerStream::MessageType msgType)
 {
+    // TODO refactor the preparation of tile layer & field dicts storage format
+    //  such that the encoding logic is not split over multiple functions.
+
     std::stringstream message;
     bitsery::Serializer<bitsery::OutputStreamAdapter> s(message);
 
diff --git a/libs/service/CMakeLists.txt b/libs/service/CMakeLists.txt
index c1586a4e..72ce3664 100644
--- a/libs/service/CMakeLists.txt
+++ b/libs/service/CMakeLists.txt
@@ -5,11 +5,13 @@ add_library(mapget-service STATIC
   include/mapget/service/cache.h
   include/mapget/service/datasource.h
   include/mapget/service/memcache.h
+  include/mapget/service/rocksdbcache.h
 
   src/service.cpp
   src/cache.cpp
   src/datasource.cpp
-  src/memcache.cpp)
+  src/memcache.cpp
+  src/rocksdbcache.cpp)
 
 target_include_directories(mapget-service
   PUBLIC
@@ -21,7 +23,8 @@ target_link_libraries(mapget-service
   PUBLIC
     stx::stx
     mapget-model
-    mapget-log)
+    mapget-log
+    rocksdb)
 
 if (MSVC)
   target_compile_definitions(mapget-service
diff --git a/libs/service/include/mapget/service/cache.h b/libs/service/include/mapget/service/cache.h
index 453c5993..29d59543 100644
--- a/libs/service/include/mapget/service/cache.h
+++ b/libs/service/include/mapget/service/cache.h
@@ -16,12 +16,7 @@ namespace mapget
  * Abstract class which defines the behavior of a mapget cache,
  * which can store and recover the output of any mapget DataSource
  * for a specific MapTileKey. Any implementation must override the
- * following methods:
- *
- *   optional<string> getTileLayer(MapTileKey const& k)
- *   void putTileLayer(MapTileKey const& k, string const& v)
- *   optional<string> getFields(string const& sourceNodeId)
- *   void putFields(string const& sourceNodeId, std::string v)
+ * methods (get|put)TileLayer and (get|put)Fields.
  */
 class Cache : public TileLayerStream::CachedFieldsProvider, public std::enable_shared_from_this<Cache>
 {
@@ -29,22 +24,33 @@ class Cache : public TileLayerStream::CachedFieldsProvider, public std::enable_s
 
 public:
     using Ptr = std::shared_ptr<Cache>;
+    // The following methods are already implemented,
+    // they forward to the virtual methods on-demand.
+
+    /**
+     * Used by DataSource to upsert a cached TileFeatureLayer.
+     * Triggers putTileLayer and putFields internally.
+     */
+    void putTileFeatureLayer(TileFeatureLayer::Ptr const& l);
+
+    /** Used by DataSource to retrieve a cached TileFeatureLayer. */
+    TileFeatureLayer::Ptr getTileFeatureLayer(MapTileKey const& tileKey, DataSourceInfo const& dataSource);
 
     // You need to implement these methods:
 
     /** Abstract: Retrieve a TileLayer blob for a MapTileKey. */
     virtual std::optional<std::string> getTileLayer(MapTileKey const& k) = 0;
 
-    /** Abstract: Upsert (Update or Insert) a TileLayer blob. */
+    /** Abstract: Upsert (update or insert) a TileLayer blob. */
     virtual void putTileLayer(MapTileKey const& k, std::string const& v) = 0;
 
-    /** Abstract: Retrieve a Fields-dict-blob for a sourceNodeId. */
-    virtual std::optional<std::string> getFields(std::string const& sourceNodeId) = 0;
+    /** Abstract: Retrieve a fields-dict-blob for a sourceNodeId. */
+    virtual std::optional<std::string> getFields(std::string_view const& sourceNodeId) = 0;
 
-    /** Abstract: Upsert (Update or Insert) a Fields-dict blob. */
-    virtual void putFields(std::string const& sourceNodeId, std::string const& v) = 0;
+    /** Abstract: Upsert (update or insert) a fields-dict blob. */
+    virtual void putFields(std::string_view const& sourceNodeId, std::string const& v) = 0;
 
-    // Override this method if your cache implementation has special stats
+    // Override this method if your cache implementation has special stats.
 
     /**
      * Get diagnostic statistics. The default implementation returns the following:
@@ -54,14 +60,6 @@ class Cache : public TileLayerStream::CachedFieldsProvider, public std::enable_s
      */
     virtual nlohmann::json getStatistics() const;
 
-    // The following methods are already implemented,
-    // they forward to the above methods on-demand.
-
-    /** Used by DataSource to retrieve a cached TileFeatureLayer. */
-    TileFeatureLayer::Ptr getTileFeatureLayer(MapTileKey const& k, DataSourceInfo const& i);
-
-    /** Used by DataSource to upsert a cached TileFeatureLayer. */
-    void putTileFeatureLayer(TileFeatureLayer::Ptr const& l);
 
 protected:
     // Override for CachedFieldsProvider::operator()
diff --git a/libs/service/include/mapget/service/memcache.h b/libs/service/include/mapget/service/memcache.h
index 38baaa7c..d60d2607 100644
--- a/libs/service/include/mapget/service/memcache.h
+++ b/libs/service/include/mapget/service/memcache.h
@@ -30,10 +30,10 @@ class MemCache : public Cache
     void putTileLayer(MapTileKey const& k, std::string const& v) override;
 
     /** Retrieve a Fields-dict-blob for a sourceNodeId -> No-Op */
-    std::optional<std::string> getFields(std::string const& sourceNodeId) override {return {};}
+    std::optional<std::string> getFields(std::string_view const& sourceNodeId) override {return {};}
 
     /** Upsert a Fields-dict blob. -> No-Op */
-    void putFields(std::string const& sourceNodeId, std::string const& v) override {}
+    void putFields(std::string_view const& sourceNodeId, std::string const& v) override {}
 
     /** Enriches the statistics with info about the number of cached tiles. */
     nlohmann::json getStatistics() const override;
diff --git a/libs/service/include/mapget/service/rocksdbcache.h b/libs/service/include/mapget/service/rocksdbcache.h
new file mode 100644
index 00000000..31efd7cd
--- /dev/null
+++ b/libs/service/include/mapget/service/rocksdbcache.h
@@ -0,0 +1,39 @@
+#pragma once
+
+#include "cache.h"
+
+#include <rocksdb/db.h>
+
+namespace mapget
+{
+
+/**
+ * A persistent cache implementation that stores layers and field dictionaries
+ * in RocksDB. Oldest tiles are removed automatically in FIFO order when cacheMaxTiles is
+ * reached.
+ */
+class RocksDBCache : public Cache
+{
+public:
+    explicit RocksDBCache(
+        uint32_t cacheMaxTiles = 1024,
+        std::string cachePath = "mapget-cache",
+        bool clearCache = false);
+    ~RocksDBCache() override;
+
+    std::optional<std::string> getTileLayer(MapTileKey const& k) override;
+    void putTileLayer(MapTileKey const& k, std::string const& v) override;
+    std::optional<std::string> getFields(std::string_view const& sourceNodeId) override;
+    void putFields(std::string_view const& sourceNodeId, std::string const& v) override;
+
+private:
+    rocksdb::DB* db_{};
+    rocksdb::Options options_;
+    rocksdb::WriteOptions write_options_;
+    rocksdb::ReadOptions read_options_;
+    std::vector<rocksdb::ColumnFamilyHandle*> column_family_handles_;
+    uint32_t key_count_ = 0;
+    uint32_t max_key_count_ = 0;
+};
+
+}  // namespace mapget
\ No newline at end of file
diff --git a/libs/service/src/cache.cpp b/libs/service/src/cache.cpp
index d365057c..e74b376f 100644
--- a/libs/service/src/cache.cpp
+++ b/libs/service/src/cache.cpp
@@ -8,11 +8,9 @@ namespace mapget
 
 std::shared_ptr<Fields> Cache::operator()(const std::string_view& nodeId)
 {
-    std::string nodeIdStr{nodeId};
-
     {
         std::shared_lock fieldCacheReadLock(fieldCacheMutex_);
-        auto it = fieldsPerNodeId_.find(nodeIdStr);
+        auto it = fieldsPerNodeId_.find(nodeId);
         if (it != fieldsPerNodeId_.end())
             return it->second;
     }
@@ -22,22 +20,34 @@ std::shared_ptr<Fields> Cache::operator()(const std::string_view& nodeId)
         std::unique_lock fieldCacheOffsetsWriteLock(fieldCacheOffsetMutex_, std::defer_lock);
         std::lock(fieldCacheWriteLock, fieldCacheOffsetsWriteLock);
 
-        // Was it inserted already now?
-        auto it = fieldsPerNodeId_.find(nodeIdStr);
+        // Was the Fields dict inserted already now?
+        auto it = fieldsPerNodeId_.find(nodeId);
         if (it != fieldsPerNodeId_.end())
             return it->second;
 
-        // Load/Insert it
-        std::shared_ptr<Fields> cachedFields = std::make_shared<Fields>(nodeIdStr);
-        auto cachedFieldsBlob = getFields(nodeIdStr);
+        // Load/insert the Fields dict.
+        std::shared_ptr<Fields> cachedFields = std::make_shared<Fields>(nodeId);
+        auto cachedFieldsBlob = getFields(nodeId);
         if (cachedFieldsBlob) {
+            // Read the fields from the stream.
             std::stringstream stream;
             stream << *cachedFieldsBlob;
-            Fields::readDataSourceNodeId(stream);
+
+            // First, read the header and the datasource node id.
+            // These must match what we expect.
+            TileLayerStream::MessageType streamMessageType;
+            uint32_t streamMessageSize;
+            TileLayerStream::Reader::readMessageHeader(stream, streamMessageType, streamMessageSize);
+            auto streamDataSourceNodeId = Fields::readDataSourceNodeId(stream);
+            if (streamMessageType != TileLayerStream::MessageType::Fields || streamDataSourceNodeId != nodeId) {
+                throw logRuntimeError("Stream header error while parsing fields from cache.");
+            }
+
+            // Now, actually read the fields message.
             cachedFields->read(stream);
-            fieldCacheOffsets_.emplace(nodeIdStr, cachedFields->highest());
+            fieldCacheOffsets_.emplace(nodeId, cachedFields->highest());
         }
-        auto [itNew, _] = fieldsPerNodeId_.emplace(nodeIdStr, cachedFields);
+        auto [itNew, _] = fieldsPerNodeId_.emplace(nodeId, cachedFields);
         return itNew->second;
     }
 }
@@ -50,26 +60,31 @@ nlohmann::json Cache::getStatistics() const {
     };
 }
 
-TileFeatureLayer::Ptr Cache::getTileFeatureLayer(const MapTileKey& k, DataSourceInfo const& i)
+TileFeatureLayer::Ptr Cache::getTileFeatureLayer(const MapTileKey& tileKey, DataSourceInfo const& dataSource)
 {
-    auto tileBlob = getTileLayer(k);
+    auto tileBlob = getTileLayer(tileKey);
     if (!tileBlob) {
         ++cacheMisses_;
         return nullptr;
     }
     TileFeatureLayer::Ptr result;
     TileLayerStream::Reader tileReader(
-        [&i, &k](auto&& mapId, auto&& layerId){
-            if (i.mapId_ != mapId)
+        [&dataSource, &tileKey](auto&& mapId, auto&& layerId){
+            if (dataSource.mapId_ != mapId) {
                 throw logRuntimeError(stx::format(
                     "Encountered unexpected map id '{}' in cache for tile {:0x}, expected '{}'",
-                    mapId, k.tileId_.value_, i.mapId_));
-            return i.getLayer(std::string(layerId));
+                    mapId,
+                    tileKey.tileId_.value_,
+                    dataSource.mapId_));
+            }
+            return dataSource.getLayer(std::string(layerId));
         },
         [&](auto&& parsedLayer){result = parsedLayer;},
         shared_from_this());
+
     tileReader.read(*tileBlob);
     ++cacheHits_;
+    log().debug("Returned tile from cache: {}", tileKey.tileId_.value_);
     return result;
 }
 
@@ -81,10 +96,11 @@ void Cache::putTileFeatureLayer(TileFeatureLayer::Ptr const& l)
         {
             if (msgType == TileLayerStream::MessageType::TileFeatureLayer)
                 putTileLayer(MapTileKey(*l), msg);
-            else if (msgType == TileLayerStream::MessageType::TileFeatureLayer)
+            else if (msgType == TileLayerStream::MessageType::Fields)
                 putFields(l->nodeId(), msg);
         },
-        fieldCacheOffsets_);
+        fieldCacheOffsets_,
+        /* differentialFieldUpdates = */ false);
     log().debug("Writing tile layer to cache: {}", MapTileKey(*l).toString());
     tileWriter.write(l);
 }
diff --git a/libs/service/src/rocksdbcache.cpp b/libs/service/src/rocksdbcache.cpp
new file mode 100644
index 00000000..9b4f8f65
--- /dev/null
+++ b/libs/service/src/rocksdbcache.cpp
@@ -0,0 +1,231 @@
+#include <rocksdb/db.h>
+#include <filesystem>
+#include <iostream>
+
+#include "mapget/log.h"
+#include "rocksdbcache.h"
+
+namespace mapget
+{
+
+// Timestamp to the tileId stored at that time. Used to delete oldest entries.
+static uint8_t COL_TIMESTAMP = 0;
+// Reverse tile->timestamp lookup.
+// Used to delete previous timestamp when a tile gets updated.
+static uint8_t COL_TIMESTAMP_REVERSE = 1;
+// Stores the actual tileId->tile data.
+static uint8_t COL_TILES = 2;
+// Field dicts. No data gets deleted from there unless clearCache=true is set.
+static uint8_t COL_FIELD_DICTS = 3;
+
+RocksDBCache::RocksDBCache(uint32_t cacheMaxTiles, std::string cachePath, bool clearCache)
+    : max_key_count_(cacheMaxTiles)
+{
+    // Set standard RocksDB options.
+    options_.create_if_missing = true;
+    options_.create_missing_column_families = true;
+
+    // Create separate column families for tile layers and fields.
+    // RocksDB requires a default column family, we use that for
+    // the timestamp->tile column.
+    std::vector<rocksdb::ColumnFamilyDescriptor> columnFamilies;
+    columnFamilies.push_back(rocksdb::ColumnFamilyDescriptor(
+        ROCKSDB_NAMESPACE::kDefaultColumnFamilyName,
+        rocksdb::ColumnFamilyOptions()));
+    columnFamilies.push_back(rocksdb::ColumnFamilyDescriptor(
+        "TileToTimestamp",
+        rocksdb::ColumnFamilyOptions()));
+    columnFamilies.push_back(rocksdb::ColumnFamilyDescriptor(
+        "Tiles",
+        rocksdb::ColumnFamilyOptions()));
+    columnFamilies.push_back(rocksdb::ColumnFamilyDescriptor(
+        "FieldDicts",
+        rocksdb::ColumnFamilyOptions()));
+
+    namespace fs = std::filesystem;
+
+    fs::path absoluteCachePath = cachePath;
+    if (fs::path(cachePath).is_relative()) {
+        absoluteCachePath = fs::current_path() / cachePath;
+    }
+    log().debug(stx::format("Initializing RocksDB cache under: {}", absoluteCachePath.string()));
+
+    if (!fs::exists(absoluteCachePath.parent_path())) {
+        throw logRuntimeError(stx::format(
+            "Error initializing rocksDB cache: parent directory {} does not exist!",
+            absoluteCachePath.parent_path().string()));
+    }
+
+    if (clearCache) {
+        rocksdb::DestroyDB(absoluteCachePath.string(), options_);
+    }
+
+    // Open the database.
+    rocksdb::Status status = rocksdb::DB::
+        Open(options_, absoluteCachePath.string(), columnFamilies, &column_family_handles_, &db_);
+
+    if(status.IsCorruption()) {
+        throw logRuntimeError(
+            "RocksDB cache is corrupted, restart with '--clear-cache 1'!");
+    }
+    else if (!status.ok()) {
+        throw logRuntimeError(stx::format(
+            "Error opening database at {}: {}",
+            absoluteCachePath.string(),
+            status.ToString()));
+    }
+
+    for (rocksdb::ColumnFamilyHandle* handle : column_family_handles_) {
+        std::unique_ptr<rocksdb::Iterator> it(db_->NewIterator(rocksdb::ReadOptions(), handle));
+        for (it->SeekToFirst(); it->Valid(); it->Next()) {
+            if (handle->GetName() == ROCKSDB_NAMESPACE::kDefaultColumnFamilyName) {
+                // Count existing tiles, since we're not clearing the cache.
+                key_count_++;
+            }
+            else if (handle->GetName() == "FieldDicts") {
+                // Update fieldCacheOffsets_ (superclass member)
+                // for each node ID by triggering cache lookup.
+                (*this)(it->key().ToString());
+            }
+        }
+    }
+
+    // Handle special case: if the cache is initialized with lower maxTiles
+    // than in the opened one, delete oldest tiles to fit limit.
+    if (!clearCache && max_key_count_ > 0 && key_count_ > max_key_count_) {
+        auto deleteCount = key_count_ - max_key_count_;
+        rocksdb::WriteBatch batch;
+        std::unique_ptr<rocksdb::Iterator>
+            it(db_->NewIterator(read_options_, column_family_handles_[COL_TIMESTAMP]));
+        it->SeekToFirst();
+        auto timeStampsToDelete = std::vector<std::string>();
+        while (deleteCount != 0) {
+            batch.Delete(column_family_handles_[COL_TIMESTAMP], it->key());
+            batch.Delete(column_family_handles_[COL_TIMESTAMP_REVERSE], it->value());
+            batch.Delete(column_family_handles_[COL_TILES], it->value());
+            it->Next();
+            --deleteCount;
+        }
+        status = db_->Write(write_options_, &batch);
+        if (!status.ok()) {
+            throw logRuntimeError("Could not trim cache!");
+        }
+        key_count_ = max_key_count_;
+    }
+
+    log().debug(stx::format("Initialized RocksDB cache with {} existing tile entries.",key_count_));
+}
+
+RocksDBCache::~RocksDBCache()
+{
+    for (auto handle : column_family_handles_) {
+        db_->DestroyColumnFamilyHandle(handle);
+    }
+    delete db_;
+}
+
+std::optional<std::string> RocksDBCache::getTileLayer(MapTileKey const& k)
+{
+    std::string read_value;
+    auto status =
+        db_->Get(read_options_, column_family_handles_[COL_TILES], k.toString(), &read_value);
+
+    if (status.ok()) {
+        log().trace(stx::format("Key: {} | Layer size: {}", k.toString(), read_value.size()));
+        log().debug("Cache hits: {}, cache misses: {}", cacheHits_, cacheMisses_);
+        return read_value;
+    }
+    else if (status.IsNotFound()) {
+        return {};
+    }
+
+    throw logRuntimeError(stx::format("Error reading from database: {}", status.ToString()));
+}
+
+void RocksDBCache::putTileLayer(MapTileKey const& k, std::string const& v)
+{
+    // If the tile exists already, delete the previous timestamp entry.
+    std::string previousTileTimestamp;
+    if (db_->Get(
+               read_options_,
+               column_family_handles_[COL_TIMESTAMP_REVERSE],
+               k.toString(),
+               &previousTileTimestamp)
+            .ok())
+    {
+        rocksdb::WriteBatch batch;
+        batch.Delete(column_family_handles_[COL_TIMESTAMP], previousTileTimestamp);
+        batch.Delete(column_family_handles_[COL_TIMESTAMP_REVERSE], k.toString());
+        rocksdb::Status status = db_->Write(write_options_, &batch);
+    }
+
+    auto timestamp = std::chrono::system_clock::now().time_since_epoch().count();
+
+    rocksdb::WriteBatch newTileBatch;
+    newTileBatch.Put(column_family_handles_[COL_TIMESTAMP], std::to_string(timestamp), k.toString());
+    newTileBatch.Put(
+        column_family_handles_[COL_TIMESTAMP_REVERSE],
+        k.toString(),
+        std::to_string(timestamp));
+    newTileBatch.Put(column_family_handles_[COL_TILES], k.toString(), v);
+
+    auto status = db_->Write(write_options_, &newTileBatch);
+
+    if (!status.ok()) {
+        throw logRuntimeError(stx::format("Error writing to database: {}", status.ToString()));
+    }
+
+    ++key_count_;
+    log().debug("Cache hits: {}, cache misses: {}", cacheHits_, cacheMisses_);
+
+    // Delete the oldest entry if we are exceeding the cache limit.
+    // TODO improve performance by using RocksDB's DeleteRange function
+    //  and not invoking deletion at every insert.
+    if (max_key_count_ && key_count_ > max_key_count_) {
+        // Iterator of the timestamp column is in tile insertion order.
+        std::unique_ptr<rocksdb::Iterator>
+            it(db_->NewIterator(read_options_, column_family_handles_[COL_TIMESTAMP]));
+        it->SeekToFirst();
+
+        rocksdb::WriteBatch purgeBatch;
+        purgeBatch.Delete(column_family_handles_[COL_TIMESTAMP], it->key());
+        purgeBatch.Delete(column_family_handles_[COL_TIMESTAMP_REVERSE], it->value());
+        purgeBatch.Delete(column_family_handles_[COL_TILES], it->value());
+        rocksdb::Status purgeStatus = db_->Write(write_options_, &purgeBatch);
+
+        if (!purgeStatus.ok()) {
+            throw logRuntimeError(
+                stx::format("Could not delete oldest cache entry: {}", purgeStatus.ToString()));
+        }
+        --key_count_;
+    }
+}
+
+std::optional<std::string> RocksDBCache::getFields(std::string_view const& sourceNodeId)
+{
+    std::string read_value;
+    auto status =
+        db_->Get(read_options_, column_family_handles_[COL_FIELD_DICTS], sourceNodeId, &read_value);
+
+    if (status.ok()) {
+        log().trace(stx::format("Node: {} | Field dict size: {}", sourceNodeId, read_value.size()));
+        return read_value;
+    }
+    else if (status.IsNotFound()) {
+        return {};
+    }
+
+    throw logRuntimeError(stx::format("Error reading from database: {}", status.ToString()));
+}
+
+void RocksDBCache::putFields(std::string_view const& sourceNodeId, std::string const& v)
+{
+    auto status =
+        db_->Put(write_options_, column_family_handles_[COL_FIELD_DICTS], sourceNodeId, v);
+
+    if (!status.ok()) {
+        throw logRuntimeError(stx::format("Error writing to database: {}", status.ToString()));
+    }
+}
+
+}  // namespace mapget
\ No newline at end of file


Test patch:
diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt
index 70d7597b..2df28f2f 100644
--- a/test/unit/CMakeLists.txt
+++ b/test/unit/CMakeLists.txt
@@ -12,7 +12,8 @@ endif()
 add_executable(test.mapget
   test-model.cpp
   test-info.cpp
-  test-http-datasource.cpp)
+  test-http-datasource.cpp
+  test-cache.cpp)
 
 add_executable(test.mapget.filelog
   test-file-logging.cpp)
diff --git a/test/unit/test-cache.cpp b/test/unit/test-cache.cpp
new file mode 100644
index 00000000..56286268
--- /dev/null
+++ b/test/unit/test-cache.cpp
@@ -0,0 +1,240 @@
+#include <bitsery/bitsery.h>
+#include <bitsery/adapter/stream.h>
+
+#include <catch2/catch_test_macros.hpp>
+#include <chrono>
+#include "mapget/http-service/cli.h"
+#include "mapget/log.h"
+#include "mapget/service/rocksdbcache.h"
+
+using namespace mapget;
+
+TEST_CASE("Cache settings via CLI", "[CLI-Cache]")
+{
+    mapget::runFromCommandLine(
+        {"--config", "../../examples/config/sample-service.toml", "serve"}
+    );
+}
+
+TEST_CASE("RocksDBCache", "[Cache]")
+{
+    mapget::setLogLevel("trace", log());
+
+    // TODO Make layer creation in test-model reusable.
+    // Currently, TileFeatureLayer deserialization test in test-model.cpp
+    // fails if layerInfo and fieldNames access is replaced with
+    // TileFeatureLayer functions.
+
+    auto layerInfo = LayerInfo::fromJson(R"({
+        "layerId": "WayLayer",
+        "type": "Features",
+        "featureTypes": [
+            {
+                "name": "Way",
+                "uniqueIdCompositions": [
+                    [
+                        {
+                            "partId": "areaId",
+                            "description": "String identifying the map area.",
+                            "datatype": "STR"
+                        },
+                        {
+                            "partId": "wayId",
+                            "description": "Globally Unique 32b integer.",
+                            "datatype": "U32"
+                        }
+                    ]
+                ]
+            }
+        ]
+    })"_json);
+
+    // Create one LayerInfo to be re-used by all DataSourceInfos.
+    std::map<std::string, std::shared_ptr<LayerInfo>> layers;
+    layers[layerInfo->layerId_] = std::make_shared<LayerInfo>(LayerInfo{
+        layerInfo->layerId_,
+        layerInfo->type_,
+        std::vector<FeatureTypeInfo>(),
+        std::vector<int>{0, 1, 2},
+        std::vector<Coverage>{{1, 2, {}}, {3, 3, {}}},
+        true,
+        false,
+        Version{0, 0, 0}});
+
+    // Create a basic TileFeatureLayer.
+    auto tileId = TileId::fromWgs84(42., 11., 13);
+    auto nodeId = "CacheTestingNode";
+    auto mapId = "CacheMe";
+    // Create empty shared autofilled field-name dictionary.
+    auto fieldNames = std::make_shared<Fields>(nodeId);
+    auto tile = std::make_shared<TileFeatureLayer>(
+        tileId,
+        nodeId,
+        mapId,
+        layerInfo,
+        fieldNames);
+    // Create a DataSourceInfo object.
+    DataSourceInfo info(DataSourceInfo{
+        nodeId,
+        mapId,
+        layers,
+        5,
+        nlohmann::json::object(),
+        TileLayerStream::CurrentProtocolVersion});
+
+    // Create another basic TileFeatureLayer for a different node.
+    auto otherTileId = TileId::fromWgs84(42., 12., 13);
+    auto otherNodeId = "OtherCacheTestingNode";
+    auto otherMapId = "CacheMeToo";
+    // Create empty shared autofilled field-name dictionary.
+    auto otherFieldNames = std::make_shared<Fields>(otherNodeId);
+    auto otherTile = std::make_shared<TileFeatureLayer>(
+        otherTileId,
+        otherNodeId,
+        otherMapId,
+        layerInfo,
+        otherFieldNames);
+    // Create another DataSourceInfo object, but reuse the layer info.
+    DataSourceInfo otherInfo(DataSourceInfo{
+        otherNodeId,
+        otherMapId,
+        layers,
+        5,
+        nlohmann::json::object(),
+        TileLayerStream::CurrentProtocolVersion});
+
+
+    auto testFieldsNodeId = "FieldDictsTestingNode";
+    auto testFieldsDictionary = Fields(testFieldsNodeId);
+
+    // Write a field dict directly into cache, including the header
+    // added in TileLayerStream::sendMessage.
+    std::stringstream serializedFields;
+    testFieldsDictionary.write(serializedFields, 0);
+
+    std::stringstream serializedMessage;
+    bitsery::Serializer<bitsery::OutputStreamAdapter> s(serializedMessage);
+    s.object(TileLayerStream::CurrentProtocolVersion);
+    s.value1b(TileLayerStream::MessageType::Fields);
+    s.value4b((uint32_t)serializedFields.str().size());
+    serializedMessage << serializedFields.str();
+
+    SECTION("Insert, retrieve, and update feature layer") {
+        // Open or create cache, clear any existing data.
+        auto cache = std::make_shared<mapget::RocksDBCache>(
+            1024, "mapget-cache", true);
+        auto fieldDictCount = cache->getStatistics()["loaded-field-dicts"].get<int>();
+        REQUIRE(fieldDictCount == 0);
+
+        // putTileFeatureLayer triggers both putTileLayer and putFields.
+        cache->putTileFeatureLayer(tile);
+        auto returnedTile = cache->getTileFeatureLayer(tile->id(), info);
+        fieldDictCount = cache->getStatistics()["loaded-field-dicts"].get<int>();
+        REQUIRE(fieldDictCount == 1);
+
+        // Update a tile, check that cache returns the updated version.
+        REQUIRE(returnedTile->size() == 0);
+        auto feature = tile->newFeature("Way", {{"areaId", "MediocreArea"}, {"wayId", 24}});
+        cache->putTileFeatureLayer(tile);
+        auto updatedTile = cache->getTileFeatureLayer(tile->id(), info);
+        REQUIRE(updatedTile->size() == 1);
+
+        // Check that cache hits and misses are properly recorded.
+        auto missingTile = cache->getTileFeatureLayer(otherTile->id(), otherInfo);
+        REQUIRE(cache->getStatistics()["cache-hits"] == 2);
+        REQUIRE(cache->getStatistics()["cache-misses"] == 1);
+    }
+
+    SECTION("Reopen cache with maxTileCount=1, insert another layer") {
+        // Open existing cache.
+        auto cache = std::make_shared<mapget::RocksDBCache>(
+            1, "mapget-cache", false);
+        auto fieldDictCount = cache->getStatistics()["loaded-field-dicts"].get<int>();
+        REQUIRE(fieldDictCount == 1);
+
+        // Add a tile to trigger cache cleaning.
+        cache->putTileFeatureLayer(otherTile);
+
+        auto returnedTile = cache->getTileFeatureLayer(otherTile->id(), otherInfo);
+        REQUIRE(returnedTile->nodeId() == otherTile->nodeId());
+
+        // Field dicts are updated with getTileFeatureLayer.
+        fieldDictCount = cache->getStatistics()["loaded-field-dicts"].get<int>();
+        REQUIRE(fieldDictCount == 2);
+
+        // Query the first inserted layer - it should not be retrievable.
+        auto missingTile = cache->getTileFeatureLayer(tile->id(), info);
+        REQUIRE(cache->getStatistics()["cache-misses"] == 1);
+        REQUIRE(!missingTile);
+    }
+
+    SECTION("Store another tile at unlimited cache size") {
+        auto cache = std::make_shared<mapget::RocksDBCache>(
+            0, "mapget-cache", false);
+
+        // Insert another tile for the next test.
+        cache->putTileFeatureLayer(tile);
+
+        // Make sure the previous tile is still there, since cache is unlimited.
+        auto olderTile = cache->getTileFeatureLayer(otherTile->id(), otherInfo);
+        REQUIRE(cache->getStatistics()["cache-misses"] == 0);
+        REQUIRE(cache->getStatistics()["cache-hits"] == 1);
+    }
+
+    SECTION("Reopen cache with maxTileCount=1, check older tile was deleted") {
+        auto cache = std::make_shared<mapget::RocksDBCache>(
+            1, "mapget-cache", false);
+        REQUIRE(cache->getStatistics()["cache-misses"] == 0);
+
+        // Query the first inserted layer - it should not be retrievable.
+        auto missingTile = cache->getTileFeatureLayer(otherTile->id(), otherInfo);
+        REQUIRE(cache->getStatistics()["cache-misses"] == 1);
+    }
+
+    SECTION("Reopen cache, check loading of field dicts") {
+        // Open existing cache.
+        auto cache = std::make_shared<mapget::RocksDBCache>();
+        REQUIRE(cache->getStatistics()["loaded-field-dicts"] == 2);
+
+        cache->putFields(testFieldsNodeId, serializedMessage.str());
+        auto returnedEntry = cache->getFields(testFieldsNodeId);
+
+        // Make sure field dict was properly stored.
+        REQUIRE(returnedEntry.value() == serializedMessage.str());
+
+        // TODO this kind of access bypasses the creation of a fieldCacheOffsets_
+        //  entry in the cache -> decouple the cache storage logic clearly from
+        //  tile layer writing logic.
+        REQUIRE(cache->getStatistics()["loaded-field-dicts"] == 2);
+    }
+
+    SECTION("Reopen cache again, check loading of field dicts again") {
+        // Open existing cache.
+        auto cache = std::make_shared<mapget::RocksDBCache>();
+        REQUIRE(cache->getStatistics()["loaded-field-dicts"] == 3);
+
+        // Check that the same value can still be retrieved from field dict.
+        auto returnedEntry = cache->getFields(testFieldsNodeId);
+        REQUIRE(returnedEntry.value() == serializedMessage.str());
+    }
+
+    SECTION("Create cache under a custom path") {
+        auto now = std::chrono::system_clock::now();
+        auto epoch_time = std::chrono::system_clock::to_time_t(now);
+
+        std::filesystem::path test_cache = std::filesystem::temp_directory_path() /
+            ("rocksdb-unit-test-" + std::to_string(epoch_time));
+        log().info(stx::format("Test creating cache: {}", test_cache.string()));
+
+        // Delete cache if it already exists, e.g. from a broken test case.
+        if (std::filesystem::exists(test_cache)) {
+            std::filesystem::remove_all(test_cache);
+        }
+        REQUIRE(!std::filesystem::exists(test_cache));
+
+        // Create cache and make sure it worked.
+        auto cache = std::make_shared<mapget::RocksDBCache>(
+            1024, test_cache.string(), true);
+        REQUIRE(std::filesystem::exists(test_cache));
+    }
+}
diff --git a/test/unit/test-model.cpp b/test/unit/test-model.cpp
index 8b4aa2f2..1e2be3ea 100644
--- a/test/unit/test-model.cpp
+++ b/test/unit/test-model.cpp
@@ -14,7 +14,7 @@ TEST_CASE("FeatureLayer", "[test.featurelayer]")
     mapget::setLogLevel("trace", log());
 
     // Create layer info which has a single feature type with
-    // a single allowed feature id composition.
+    // several allowed feature id compositions.
     auto layerInfo = LayerInfo::fromJson(R"({
         "layerId": "WayLayer",
         "type": "Features",