slap_files

slap_files is a service for storing files in object storage.

A file has a ref, {partition, id}, such as {document_id, attachment_id}; a body (its contents); and a content type, size, checksum, and your own metadata. You can list the files in a partition, such as a document's attachments, and make a replacement conditional on the file not having changed since you read it. A read can return a byte range of a file, for HTTP Range requests. When a file is replaced or deleted, its old body is deleted for you, and so are objects left behind by an interrupted upload or delete.

Each file is one record in slap_kv. With the default storage: :auto, a body up to 16 KiB is stored in the record, and a larger one is uploaded as a separate object (with the record holding the object's key). SlateDB batches slap_kv writes into shared PUTs, so storing many small files takes fewer PUTs than storing each one as its own object.

Use slap_files when files are replaced or deleted while other processes or nodes may be reading them, and you want their metadata, listing, conditional replacement, and cleanup handled for you. If object keys are enough for your application and you already handle those, use object storage directly instead of slap_files.

About Slap {: .info}

This package is part of Slap.

Slap provides Elixir bindings for SlateDB (an embedded key-value engine optimized for storing data in object storage), as well as services built on top of these bindings. The services run inside your application, on one node or several.

Services:

Building blocks:

Standalone server:

Installation

Add slap_files to the dependencies in your application's mix.exs:

defp deps do
  [
    {:slap_files, "~> 0.2.0"}
  ]
end

Run mix deps.get. API documentation is on HexDocs.

Example

In an application that depends on slap_files, start Slap.KV.Cluster before Slap.Files. For example, in iex -S mix with a fresh local directory:

iex> store = {:local, "/tmp/slap-files"}
{:local, "/tmp/slap-files"}
iex> {:ok, _} = Slap.KV.Cluster.start_link(store: store, path: "kv", shards: 8); :ok
:ok
iex> {:ok, _} = Slap.Files.start_link(store: store, path: "files"); :ok
:ok

iex> ref = {"doc-42", Slap.Files.new_id()}; :ok
:ok
iex> {:ok, %Slap.Files.File{version: v1, storage: :inline}} =
...>   Slap.Files.put(ref, "hello", content_type: "text/plain", if_version: :absent); :inline
:inline
iex> Slap.Files.read(ref)
{:ok, "hello"}

iex> body = :binary.copy("x", 20_000); byte_size(body)
20000
iex> {:ok, %{version: v2, storage: :object}} = Slap.Files.put(ref, body, if_version: v1); :object
:object

iex> {:ok, ^body} = Slap.Files.read(ref); byte_size(body)
20000
iex> {:ok, %{files: files, cursor: nil}} = Slap.Files.list("doc-42"); length(files)
1
iex> :ok = Slap.Files.delete(ref, if_version: v2)
:ok

In an application, put both processes in its supervision tree in the same order. Slap.Files.stream/1 returns the metadata and a lazy body stream.

API

A partition is a stable group of files, such as a document's attachments, that is listed together and lives on one slap_kv shard. There is no listing across partitions, so to find files that nothing references, an application lists the partitions it knows.

Storage

The default instance uses the "default" namespace in slap_kv partitions and object keys. Named instances require an explicit, stable namespace:.

Cleanup

An old object body remains available for retention_ms (default 5 minutes) after a replacement or delete, so a reader that already opened it can finish. Interrupted uploads and writes may leave objects temporarily; background sweeps and reconciliation remove them. Nodes' clocks must be within max_clock_skew_ms (default 30 seconds) of each other for cleanup to be safe.

How cleanup works

Files are hashed into 64 buckets. Each bucket has a slap_kv partition of intents and a slap_kv partition of object registrations, and its objects share a key prefix.

The steps:

A writer and the sweeper both take the intent before acting, so only one of them acts. An upload slower than upload_timeout_ms loses to the sweep and returns {:error, :expired}, instead of pointing a file at a deleted object. A write or delete that fails part-way leaves an intent, and the sweep finishes the cleanup. Reconciliation deletes anything an upload wrote too late.

Limits

Benchmarks

bench/inline.exs compares inline and object bodies on RustFS. Inline writes need one durable slap_kv write; object writes also upload the body and manage an intent. Inline bodies save object-store PUTs, but they increase record size and make listings read more data. The total cost also depends on slap_kv reads, writes, and compaction, and on your object store's pricing. Measure with representative file sizes before changing inline_max_bytes.

Tests

mix test          # SLAP_FILES_PROP_RUNS=500 for more model sequences

test/s3_test.exs reads byte ranges of object bodies from an S3-compatible server such as RustFS. It runs when SLAP_TEST_S3_ENDPOINT is set; SLAP_TEST_S3_BUCKET names the bucket (default slatedb-test). CI runs it against RustFS.

The model test covers writes, deletes, reads, sweeps, and clock changes. test/sweeper_test.exs checks interrupted uploads and cleanup.

License

Slap is released under the terms of the Apache License 2.0.

Copyright (c) 2026, Michael Russo.