May 9, 2024·5 min read·engineeringdragonflystoragememoryjson

Dragonfly tests SSD tiering for string values

Dragonfly 1.18 introduces an explicitly experimental tiering implementation. Its background offload loop makes the memory and I/O tradeoff visible at the level of individual string values.

Moving cache values to SSD changes what it means for a database to fit on one machine. Some of the payload can leave memory, but reads and mutations must still find the correct value while disk operations are in progress. Dragonfly's background offloading implementation makes that transition explicit. It traverses the key table, issues storage requests for eligible values, and keeps its place for the next traversal.

Dragonfly 1.18.0, released on May 9, introduces this tiering work as an alpha for string values. The maintainers explicitly say not to use it in production. That status belongs in any account of the release because the ability to reduce resident value memory is different from a claim that the complete storage lifecycle is ready for production workloads.

I am interested in the direction because scaling one cache machine is often simpler to operate than adding a cluster. SSD tiering extends that idea beyond adding RAM. It also changes the execution costs of ordinary string commands, so its mechanism matters more than the fact that a disk path can be configured.

Offloading starts from memory pressure

The release enables the experiment through a path prefix. This is the option supplied by the announcement, with the path chosen to identify storage on an SSD.

--tiered_prefix=/pathto/ssd/base

The background-loop change connects offloading to the shard heartbeat. The code derives a tiering threshold from the configured memory limit and the shard count. When usage passes that threshold, the heartbeat asks the tiered-storage manager to offload values from the database. This makes offloading a response to memory usage within the running engine.

Eligibility is deliberately restricted. The implementation selects string objects that have not already moved to external storage and meet its minimum value size. It skips objects with pending I/O. Those conditions avoid issuing another storage request for a value that is already external or already moving. They also mean a mixed dataset's total size is not the amount of memory the feature can remove.

The versioned storage interface defines operations for reading, modifying, stashing, and deleting external values. Its pending-I/O handling includes cancellation. These interfaces reflect the fact that a string can change or disappear while an asynchronous offload is underway. Completion has to respect the current state of the key.

The traversal has a bound on the number of outstanding stash requests and a bound on its own iterations. It retains a cursor so later calls can resume. That design avoids treating memory pressure as permission to scan the entire database without yielding responsibility back to the engine. The code comment identifies responsiveness as the reason to bound the traversal.

A disk location replaces the resident payload

On completion, the manager records an external disk segment in place of the original value and updates memory statistics. Fetching reverses that accounting when the value returns to memory. The background-offload test writes strings, waits for them to become external, reads them, and then waits for a second round of offloading. Its counters check both storage and fetch operations.

This reveals an ongoing policy interaction. Reading a value can bring it back into memory, after which the background loop can offload it again under the test's forced conditions. An application's access pattern therefore affects the amount of I/O as well as the resident dataset. The release does not provide a workload-independent claim about how frequently that cycle occurs.

Mutation requires its own integration. The APPEND support change adds a modification operation to the storage operation manager and exposes it through the tiering layer. An external string still has to support commands that alter its contents. Recognizing the type and locating its bytes do not by themselves implement that mutation.

The platform boundary is also explicit in the versioned header. Linux receives the storage implementation, while other platforms receive a stub. The release's native macOS build should consequently not be read as a statement that this experimental storage path has the same implementation there. A project can build on a platform while an individual subsystem remains platform-specific.

I regard these details as the useful content of an alpha announcement. They identify the states and operations that the implementation is beginning to cover. They also explain why a successful GET after offloading is only one part of evaluating a storage feature, alongside modification and cancellation of pending work.

JSON merge support adds a precise update language

The same release includes JSON.MERGE. Its proposal identifies JSON Merge Patch as the specification and gives the command shape JSON.MERGE key path value. This supplies a structured update operation independently of the tiering experiment, which the release restricts to strings.

RFC 7386 defines merge patches by comparing an object-shaped patch with the target object. New members are added, existing members are replaced or recursively merged, and a member with a null value removes the corresponding member from the target. The null behaviour is a deliberate part of the format, not an ordinary assignment of JSON null.

For example, a patch containing {"enabled":true,"obsolete":null} expresses an update to enabled and removal of obsolete. Arrays have different semantics. A merge patch replaces an array as a value, so it cannot express an edit to just one array element through the array's contents. The RFC also explains that the operation works on data items and need not preserve textual whitespace or member ordering.

Those semantics matter when choosing an application update format. An object-heavy document can make a small merge patch convenient, while a document that treats explicit null as business data needs to account for its deletion meaning. The specification describes both the convenience and the representational limit.

Replication buffers have their own memory lifecycle

The release also includes a replication streaming-buffer correction. The author explains that the buffer can grow when journaling cannot await, and the patch shrinks it if it becomes too large. This concerns transient replication memory rather than the resident string payloads selected for tiering.

Keeping those categories separate makes the release easier to interpret. Moving eligible values to SSD can reduce one part of memory usage, while replication retains its own buffering requirements. Version 1.18 addresses both through different mechanisms. The tiering work changes value placement, and the replication change limits retention of a buffer after it has grown.