Skip to main content

Storage Providers

Whoosh-NG provides pluggable storage backends through the SyncStorageProvider / AsyncStorageProvider contracts. This allows the index to be persisted on local disk, SQLite, S3, or a hybrid cache + remote setup without changing the writer or the index.

Architecture Overview​

Level 1: SnapshotStorage (Simple)​

Writer → Local FS → Commit → Upload Segment → S3
Reader → Download Segment → Open locally

Very simple to maintain. Use SnapshotStorage when you want S3 as a simple backup/restore target without the complexity of a local cache.

+----------+
| MinIO |
+----------+
^
|
Sync |
v
+-----------+ Cache Layer +-----------+
| Searcher |<--------------->| Writer |
+-----------+ +-----------+
|
v
Local SSD
  • Index lives on SSD
  • S3 serves as replication
  • Segments are pushed after commit
  • Restoration possible at any moment

This is what many modern distributed search systems do.

Available providers​

ProviderTypeBackendUse Case
FileStoragesynclocal filesystemSingle-node, no cloud
AsyncFileStorageasynclocal filesystemSingle-node async
S3StoragesyncS3-compatibleDirect S3 access
SnapshotStoragesyncS3-compatibleSimple backup/restore
HybridStoragesynclocal cache + remoteProduction (alias: CachedObjectStorage)
AsyncHybridStorageasynclocal cache + remoteProduction async
CoreStorageAdaptersynccore FileStorage → modern SyncStorageProviderBridge legacy core backends into the modern pipeline

All providers are importable from whoosh_modern.storage.

FileStorage​

Local filesystem storage. Keys are relative paths under root.

from whoosh_modern.storage import FileStorage

storage = FileStorage("indexdir")
storage.write("segment_1.dat", b"data")
assert storage.read("segment_1.dat") == b"data"
assert storage.exists("segment_1.dat") is True
storage.delete("segment_1.dat")
keys = storage.list_keys()

CoreStorageAdapter​

Wraps a core whoosh.filedb.filestore.FileStorage instance behind the modern SyncStorageProvider interface. This lets you drop an existing core storage backend into a modern HybridStorage or StorageMiddleware chain without modifying the core class.

from whoosh.filedb.filestore import FileStorage as CoreFileStorage
from whoosh_modern.storage import CoreStorageAdapter

core = CoreFileStorage("indexdir")
adapter = CoreStorageAdapter(core)

adapter.write("segment_1.dat", b"data")
assert adapter.read("segment_1.dat") == b"data"
assert adapter.exists("segment_1.dat") is True
adapter.delete("segment_1.dat")

AsyncFileStorage​

Async variant of FileStorage. All operations run on a worker thread via asyncio.to_thread so the event loop is never blocked.

import asyncio
from whoosh_modern.storage import AsyncFileStorage

storage = AsyncFileStorage("indexdir")

async def main() -> None:
await storage.awrite("segment_1.dat", b"data")
data = await storage.aread("segment_1.dat")
await storage.adelete("segment_1.dat")

asyncio.run(main())

S3Storage​

S3-compatible blob storage. boto3 is imported lazily, so it is an optional dependency. A client can be injected for testing.

from whoosh_modern.storage import S3Storage

# Default client (requires boto3 installed and configured)
storage = S3Storage(bucket="my-index-bucket", prefix="segments")

# Or inject a client for testing / custom configuration
storage = S3Storage(
bucket="my-index-bucket",
prefix="segments",
client=my_boto3_client,
)

storage.write("segment_1.dat", b"data")
data = storage.read("segment_1.dat")
keys = storage.list_keys()

Install the optional dependency:

pip install whoosh-ng[s3]

SnapshotStorage​

Simple S3 snapshot storage. S3 remains the source of truth; on read, the object is downloaded from S3 and also persisted under local_path so subsequent reads of the same key can be served from the local scratch copy:

  • Write: upload segment directly to S3
  • Read: download segment from S3, cache it under local_path

Use this when you want S3 as a simple backup/restore target without the complexity of a local cache.

from whoosh_modern.storage import SnapshotStorage

storage = SnapshotStorage(
local_path="./index",
bucket="my-index-bucket",
prefix="snapshots",
)

storage.write("segment_1.dat", b"data")
data = storage.read("segment_1.dat")

HybridStorage / CachedObjectStorage​

HybridStorage composes a local cache and a remote backend. The remote is the source of truth; the local cache is a write-through performance layer.

CachedObjectStorage is an alias for HybridStorage that better conveys the intent: a local object cache synchronized with S3.

This is the recommended architecture for production deployments with repeated read patterns.

from whoosh_modern.storage import HybridStorage, S3Storage

remote = S3Storage(bucket="my-index-bucket", prefix="segments")
storage = HybridStorage(local_cache="./cache", remote=remote)

# Write-through: remote is source of truth, cache is updated on success
storage.write("segment_1.dat", b"data")

# First read: cache miss → fetch from S3, write-through into cache
data = storage.read("segment_1.dat")

# Second read: cache hit → served from local disk, zero network
data = storage.read("segment_1.dat")

# Force refresh from remote
storage.invalidate("segment_1.dat")

# Warm cache proactively
storage.prefetch(["segment_2.dat", "segment_3.dat"])

Read path​

  1. local cache hit → return immediately
  2. cache miss → read from remote, write-through into cache, return

Write path​

  • remote.write(key, data) (source of truth)
  • on success → local_cache.write(key, data)
  • on failure → raise before polluting cache

Cache eviction​

The local cache is bounded by max_cache_size_mb (default 1024 MB). When the limit is reached, the oldest entries are evicted using an LRU policy.

list_keys​

list_keys() uses the remote as source of truth because the cache is only partial. Pass include_cache=True to return the union of remote and cache keys.

AsyncHybridStorage​

Async variant of HybridStorage. Remote operations are executed on a worker thread via asyncio.to_thread so the event loop is never blocked.

import asyncio
from whoosh_modern.storage import AsyncHybridStorage, S3Storage

remote = S3Storage(bucket="my-index-bucket", prefix="segments")
storage = AsyncHybridStorage(local_cache="./cache", remote=remote)

async def main() -> None:
await storage.awrite("segment_1.dat", b"data")
data = await storage.aread("segment_1.dat")
await storage.adelete("segment_1.dat")
keys = await storage.alist_keys()

asyncio.run(main())

Using storage with SearchApplication​

from whoosh_modern import SearchApplication, SQLSource
from whoosh_modern.storage import HybridStorage, S3Storage

remote = S3Storage(bucket="my-index-bucket", prefix="segments")
storage = HybridStorage(local_cache="./cache", remote=remote)

app = SearchApplication(
source=SQLSource(query="SELECT * FROM products", connection=engine),
storage=storage,
)
app.build()
results = app.index.search("laptop")

Performance Benchmarks​

Benchmarks were run against a local MinIO instance using a 28.89 MB Whoosh index (2 segment files). Results are indicative of relative performance between strategies on S3-compatible storage.

StrategyBackup (MB/s)Restore (MB/s)Notes
1_obj_per_segment39.44139.72Best restore throughput; simplest
compressed_zstd31.56133.74Lower bandwidth, CPU overhead
hybrid_cache_s344.97133.61Best backup; excellent warm-cache reads
1_obj_per_posting_list0.284.79Avoid: millions of small objects kill S3

Recommendations​

  • Default: S3Storage with 1 object per segment file. It offers the best restore throughput and is the simplest to operate.
  • Production with repeated reads: HybridStorage(local_cache, S3Storage). After the first read, subsequent reads are served from local disk at ~133 MB/s.
  • Avoid: 1 object per posting list. S3 is not optimized for millions of tiny objects; latency and cost explode.
  • Compression: ZSTD reduces transfer size by ~20-30% at the cost of CPU. Use it when network bandwidth is the bottleneck, not when CPU is.

Running the benchmarks​

# Start MinIO
docker run -d --name minio-benchmark -p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:latest server /data --console-address ":9001"

# Run synthetic benchmark
python benchmark/s3_storage_benchmark.py

# Run real Whoosh index benchmark (requires customers CSV)
python benchmark/s3_storage_benchmark_real.py

How Storage Providers Integrate into the Indexing and Search Pipeline​

The storage provider participates in two distinct phases: index creation (determining where segments live) and runtime pipeline integration (via StorageMiddleware).

Full indexing flow with a storage provider​

DataSource.stream_batches()
│
▼
SearchApplication.build()
│
├── source.discover_schema() ──► Whoosh Schema
│
├── storage root resolution
│ │
│ ├── FileStorageProvider (exposed as `FileStorage`)
│ │ └── exposes a public `root` ──► whoosh.index.create_in(root, schema)
│ │
│ └── Other providers (S3 / Snapshot / Hybrid, no filesystem root)
│ └── tempfile.mkdtemp() ──► create_in(tmpdir, schema)
│
├── Writer = index.writer()
│ │
│ ├── MiddlewareChain.before_index()
│ │ └── StorageMiddleware.before_index()
│ │ ├── context.labels["storage_backend"] = provider.__class__.__name__
│ │ └── context.metadata["storage_provider"] = self
│ │
│ ├── for batch in source.stream_batches():
│ │ for doc in batch:
│ │ writer.add_document(**doc)
│ │
│ └── writer.commit()
│ │
│ └── StorageMiddleware.on_commit()
│ └── provider.write("commits/{name}/{timestamp}", b"1")
│
▼
Index persisted on disk / S3 / hybrid cache

Full search flow with a storage provider​

SearchApplication.search(query)
│
├── index.searcher()
│ │
│ └── Whoosh core opens segment files from:
│ ├── local filesystem (FileStorageProvider root)
│ ├── SQLite DB (SQLiteStorageProvider)
│ └── S3 / hybrid cache (S3StorageProvider / HybridStorage)
│
├── QueryParser.parse(query) ──► Query object
│
└── searcher.search(query)
│
└── Whoosh core reads posting lists from segment files
└── Returns Results (Hits)

StorageMiddleware hooks in detail​

StorageMiddleware (whoosh_modern.middleware.storage) is the integration point that routes index persistence through any SyncStorageProvider without modifying the writer.

HookWhenWhat it does
before_index(context)Before each document is addedTags the context with storage_backend label and storage_provider metadata
on_commit(context)After writer.commit()Writes a commit checkpoint marker (commits/{name}/{timestamp}) to the provider

Example: StorageMiddleware with a custom chain​

from whoosh_modern.middleware import (
StorageMiddleware,
FileStorageProvider,
StemmingMiddleware,
)
from whoosh.middleware.chain import MiddlewareChain
from whoosh_modern.analysis import get_stemmer

# Storage provider
storage = FileStorageProvider("/data/index")

# Create middleware chain
chain = MiddlewareChain([
StorageMiddleware(storage, name="primary"),
StemmingMiddleware(stemmer=get_stemmer("auto", "english").stem),
])

# Apply to writer
from whoosh.middleware.wrappers import MiddlewareWriter

with MiddlewareWriter(ix.writer(), chain) as writer:
writer.add_document(title="Hello", content="World")
# StorageMiddleware.before_index() tags the context
# StemmingMiddleware stems the fields
# writer.commit() triggers StorageMiddleware.on_commit()
writer.commit()

Key insight: StorageProvider vs StorageMiddleware​

ComponentRole
SyncStorageProvider / AsyncStorageProviderContract defining write(), read(), delete(), exists(), list_keys()
FileStorageProvider, S3StorageProvider, HybridStorageImplementations of the contract
StorageMiddlewareIntegration layer that calls the provider at specific lifecycle hooks (before_index, on_commit)
SearchApplicationEntry point that delegates to SearchView.build(); when the storage is a FileStorageProvider (exposed as FileStorage) it uses its public root to create the Whoosh index directory, otherwise it falls back to a temporary directory

The provider itself does not intercept Whoosh's internal segment reads. Those reads go through Whoosh's built-in FileStorage (whoosh.filedb.filestore) which reads from the filesystem path given to create_in(). The Whoosh-NG storage provider abstraction is designed for:

  • Custom segment routing (S3, SQLite, hybrid cache)
  • Commit checkpointing via middleware
  • Future: segment-level read/write interception

See Also​