Switching from python-diskcache
If you’re here, the question is a practical one: what breaks? Very little, and on purpose. diskcache-oxide is API compatible with python-diskcache 5.6.3’s Cache, and deliberately not data compatible. Keeping diskcache’s API makes switching a one-line change. Not keeping its on-disk format is what made it possible to fix the design instead of inheriting it, pickle and all. So the switch itself is small, and the rest of this page is about what you’ll notice afterwards, which is mostly things getting better and now and then things getting stricter.
For most apps, switching looks like this:
# from diskcache import Cache, Timeout
from diskcache_oxide import Cache, Timeout
cache = Cache('/var/cache/my-app-oxide')Start with a fresh cache
diskcache-oxide stores its data in diskcache-oxide.sqlite, not diskcache’s cache.db, and never opens or converts a diskcache database. The two can even share a directory without seeing each other’s data. Your cache starts cold after the switch, which for a cache is usually fine. The cleanest switch gives diskcache-oxide a new directory and deletes the old one once every worker has moved. If the two share a directory, diskcache’s files are cache.db* and its value folders, which nest two levels deep (3f/a2/….val); diskcache-oxide’s folders are one level deep and hold .val files directly, so a two-hex-digit folder that contains folders is diskcache’s.
Upgrading diskcache-oxide
During 0.x, a release that changes the on-disk format bumps the minor version and says so first in its changelog entry. Delete existing caches when you upgrade to it, with every worker stopped. Until you do, opening one raises FormatMismatchError, which a Caches registry reports and answers with a NullCache. A release that keeps the format needs nothing.
What you might notice
Most of these are fixes. Each one has a conformance test, or an entry in Divergences that explains why. If the switch turns up an exception you haven’t seen before, Errors and what to do about them covers each one.
| python-diskcache 5.6.3 | diskcache-oxide | |
|---|---|---|
| Values | Pickled | msgpack (default) or JSON with exact type tags. Pickle is opt-in with pickle=True. |
| Unrepresentable values | Pickled | TypeError at set(), unless pickle=True |
| Keys | Anything picklable | str only for now |
bool(cache) |
False when empty | Always True; NullCache is False |
| Timeout | Subclasses Exception |
Subclasses TimeoutError |
| directory | Optional (makes a temp dir) | Required |
| Clock | Reads time.time() |
Reads the clock in Rust; pass clock= to control it |
| size_limit | Anything (tiny values silently empty the cache) | At least 1 MiB |
expire=True / timeout=True |
Treated as 1 |
TypeError |
disk=, eviction_policy= |
Supported | Not supported (eviction policies are coming) |
| transact() | tx is None |
tx is the cache; see transact() below |
| FanoutCache | Supported | Supported, with differences; see FanoutCache below |
Tags, memoize |
Supported | Coming in later phases |
Values: no pickle unless you ask
diskcache pickles every value. That’s convenient, but anyone who can write to the cache directory can make your app run arbitrary code the next time it reads, and objects that were never meant to be cached get stored without complaint.
diskcache-oxide stores values as msgpack (or JSON), and tags the types those formats would otherwise change, so tuples, sets, frozensets, datetimes, dates, Decimals, big ints, NaN, nested bytes and non-str dict keys all come back exactly as stored. Something that can’t be represented raises TypeError when you store it, not later when you read it:
cache.set('job', MyJob())
# TypeError: values of type MyJob can't be stored without pickle (only exact built-in types round-trip)If you really do need to cache arbitrary objects, opt in for that cache:
objects = Cache('/var/cache/my-app-objects', pickle=True)A cache opened without pickle=True never unpickles. If it finds a pickled value written by a pickle=True peer, it raises UnsafeValueError and leaves the value alone. More in Serialization.
String keys
Only str keys are accepted today; anything else raises TypeError. Keys are already stored with a type tag, so other key types can be added later without changing the file format (see the roadmap). In the meantime, build string keys yourself (f'user:{user_id}').
transact()
with cache.transact(): works as it does in diskcache: the block’s calls on the entering thread commit together on exit and roll back on an exception, a nested block joins the outer one, its exit commits nothing, and an exception caught inside the outer block keeps the inner writes. What differs (details in Transactions):
txis the cache instead ofNone, and a second Cache handle on the same directory joins the transaction instead of waiting for it (D-029).- A transaction on a second cache whose write lock another thread holds raises TransactionBusy, a Timeout subclass, at once, and
retry=Trueretries only Timeout, never TransactionBusy. diskcache waits, and itsretry=Trueloops on anyOperationalError(D-030). - Another thread may end it. Its commit or rollback runs within the transaction’s timeout, and the owner’s own exit then raises
TransactionAborted(aRuntimeError) unless it committed (D-031). The object transact() returns can be entered once. - A block that didn’t commit raises at its exit with
TransactionAborted, aRuntimeError, for example after a write inside it failed on a full disk. durable=True, which diskcache doesn’t have, still raises when a transaction can’t begin and for any failure inside one (D-028).- Forking inside your own transaction poisons the child’s copy, whose writes raise
RuntimeErrorat once, instead of stalling for the timeout (D-020). - It belongs to a thread, not an asyncio task, as in diskcache: don’t
awaitinside the block.
FanoutCache
FanoutCache is covered in full in FanoutCache: sharded caches. The differences from diskcache’s, each with an entry in Divergences:
- shards is keyword-only (
FanoutCache(path, shards=4)).FanoutCache(path, 4)is a four-second timeout here. The constructor isFanoutCache(directory, timeout=None, *, shards=None, ...), and directory is required (D-039). - The shard count is recorded, and a different
shards=raises LayoutMismatchError instead of silently sending keys to other shards.shards=Noneuses the stored count, or creates 8 (D-032). - A plain Cache and a FanoutCache refuse each other’s directories with LayoutMismatchError, and nothing is created by the refused open (D-038).
- Shards are chosen by XXH3, not adler32, so which shard a key lands in differs from diskcache’s, and
shard_of(key)tells you. Iteration goes shard by shard, anditerkeys(prefix=, reverse=)gives the merged sorted order. There’s noreversed()yet (D-033). - A busy shard raises Timeout after timeout (60 seconds by default) instead of silently giving False or the default after 10 ms.
durable=Truegives the miss answers, and logs them (D-034). - Maintenance shares one timeout across the shards, doesn’t retry forever, and reports partial progress: a failure raises with
failed_shardsandremoved(D-035). - size_limit is a total, split evenly with at least 1 MiB per shard, and it reads back as the total, not shard 0’s share (D-036).
max_connectionsis a total across the shards, so file descriptors are bounded and not one per thread per shard. It must be at least shards (D-037).- transact() takes a key and covers one shard:
with cache.transact(key=k):where diskcache’swith cache.transact():locks every shard. Calls for keys on other shards inside the block raiseValueError, reads included. A ported bare transact() raisesTypeError(D-040). - Not there yet:
cache(),deque()andindex(), stats(),evict()and tags,check(),reset(),read(),reversed(),retry=,read=andtag=arguments, settings as attributes, and pickling (D-039).
Truthiness
In diskcache, an empty cache is falsy, so if cache: quietly skips caching right after every deploy, when the cache is empty. In diskcache-oxide, bool(Cache) is always True and bool(NullCache) is always False, so if cache: means “is caching on?”. Both have an enabled property that says the same thing.
Tests that patch time.time
diskcache reads time.time() in Python, so tests could advance its clock with unittest.mock.patch or a freezegun-style fixture. diskcache-oxide reads the clock natively, which keeps a Python call off every operation. To make it follow a patched clock, pass clock=:
cache = Cache(tmp_path, clock=lambda: time.time())
with patch('time.time', return_value=1000.0):
cache.set('k', 'v', expire=10)
with patch('time.time', return_value=1015.0):
assert cache.get('k') is NoneThings that just get better
You don’t have to change anything for these:
- Reads take about half the time, and threaded throughput no longer collapses as threads are added. See Performance.
- A child process forked while another thread was writing can always write. In diskcache it can stall for the full 60-second timeout.
- clear() keeps writes that land while it runs, expire() removes every expired entry, and a read during an overwrite never misses.
- File descriptors and memory are bounded by a connection pool, not by how many threads touched the cache. A FanoutCache bounds them for the whole root, not per shard.
- Opening a cache and reading from it never writes to the database.