Cache contract — oslo.cache — branch stable/2026.2
Branch
stable/2026.2@fc96999a99e4— repository https://opendev.org/openstack/oslo.cache — analysed on 2026-10-28 Measured with dogpile.cache 1.5.0, python-memcached 1.62, pymemcache 4.0.0, redis-py 8.1.0, Python 3.13
This file says what an OpenKeel service written in Go must do to share the same memcached (or Redis) as the Python OpenStack services: read their entries, write entries they can read, and invalidate them. It is backed by 19 scenarios recorded against fake servers (§ 6).
1. What sharing means, and when it is needed
Python services only read their own keys: the key contains the Python module and function name (§ 2). A Go service therefore shares an entry with a Python service in only three cases:
- The same logical key is computed on both sides: a Go Keystone that coexists with a Python Keystone during a migration, behind the same memcached.
- The cache is a published interface: keystonemiddleware's token cache, read by every service. It does not go through dogpile; its contract is in reverse-spec-keystonemiddleware 03.
- Invalidation: a Go component must make Python processes drop an entry.
Otherwise, a Go service can use the same memcached with its own key format, as long as its keys cannot collide with 40-character hexadecimal keys. Recommended: a prefix such as ok:.
2. Keys: requirements
| # | Requirement | Proof |
|---|---|---|
| K1 | Wire key = hex(sha1(utf8(logical key))), 40 lowercase characters, no prefix | scenarios 1–3, 12 |
| K2 | Memoization logical key = <Python module>:<function name>| + the positional arguments converted with str() and joined by a space. self/cls dropped; default values included; with a namespace: <module>:<function>|<namespace>|<args> | scenarios 1, 17 |
| K3 | Python str() must be reproduced exactly for each argument type: True, None, ['a', 'b'], {'k': 1}… A Go service that coexists with Python on the same function must format like Python, or not share | scenario 17 |
| K4 | Keystone: logical key + : + str(region_id) before SHA-1, where region_id is the bytes value stored under <<<region>>>:<region name> (so the suffix looks like b'\x8f…') | keystone:keystone/common/cache/core.py:76-89 |
3. Values: requirements
| # | Requirement | Proof |
|---|---|---|
| V1 | memcached: flags 1, value = pickle of dogpile.cache.api.CachedValue(payload, {"ct": <float>, "v": 2}). A reader must accept protocol 0 (python-memcached, with copy_reg._reconstructor / __builtin__.tuple and the memo) and protocols 2 to 5 (pymemcache) | scenarios 1–4, 13 |
| V2 | A Go writer can produce protocol 2 with the global dogpile.cache.api CachedValue and a 2-tuple of arguments: Python reads it | scenario 13 |
| V3 | Redis: <JSON {"ct": …, "v": 2}>|<pickle of the payload>, no flags | scenarios 18, 19 |
| V4 | v ≠ 2 is a miss for Python readers | dogpile CacheRegion._is_cache_miss |
| V5 | The payload is an arbitrary Python object. Shared entries must contain only types that a Go pickle library can map: dict, list, tuple, str, int, float, bool, None. Keystone caches its own objects too; those cannot be shared [inferred] | — |
| V6 | Security: unpickling executes code chosen by whoever wrote the value. Anyone who can write to memcached can run code in every Python service sharing it. A Go reader must use a restricted unpickler (allowed globals: dogpile.cache.api.CachedValue, copy_reg._reconstructor, __builtin__.tuple, builtins.tuple), and memcached must stay on a private network | pickle format |
4. Lifetimes and invalidation: requirements
| # | Requirement | Proof |
|---|---|---|
| T1 | Freshness: a reader serves the value only if now - ct ≤ its TTL ([group] cache_time, otherwise [cache] expiration_time, 600 s). ct is a float of Unix seconds from the writer's clock: clocks must be synchronised | scenarios 6, 7 |
| T2 | Server TTL: exptime = backend_expiration_time if configured, otherwise 0. A Go writer should send the same value, never less than the readers' TTL | scenario 5 |
| T3 | Miss: read, then read again under a lock local to the process, compute, write. No distributed lock (distributed_lock defaults to false), so several processes may compute the same value at the same time | scenario 1 |
| T4 | [cache] enabled = false → no traffic at all (null backend). [group] caching = false → reads but no writes | scenarios 8, 9 |
| T5 | Invalidation visible to Python = delete <key>, or, for Keystone, a new region id under <<<region>>>:<name>. region.invalidate() is local to the process | scenario 10 |
| T6 | set_multi / delete_multi = one command per key (python-memcached); get_multi = one get k1 k2… | scenario 11 |
5. Connections and failures: requirements
| # | Requirement | Proof |
|---|---|---|
| C1 | With several memcached servers, the Go client must choose the server like the Python client used opposite it: python-memcached = ((crc32(key) >> 16) & 0x7fff) % n, in the order of memcache_servers; pymemcache = rendezvous hashing. Python services using different clients already disagree | 03 § 4 |
| C2 | Behaviour to keep when memcached is down: miss without error (python-memcached and memcache_pool) | scenarios 14, 15 |
| C3 | TLS or SASL → binary protocol (python-binary-memcached) | oslo_cache/backends/memcache_pool.py:70-91 |
| C4 | Configuration: accept the same [cache] options and refuse the same combinations (02 § 1), except the two defects listed there | _rewrite/config-mapping_stable-2026.2.json |
6. Scenarios (19/19)
Run with the oslo_cache.py adapter of the generate-reverse-spec skill:
conformance_capture.py scenarios <oslo.cache> --branch stable/2026.2 --adapter adapters/oslo_cache.py --workdir <tmp> --out <tmp>/cc
The adapter serves a fake memcached (text protocol: get/gets/set/add/replace/cas/append/prepend/delete/touch/incr/decr/flush_all/version) and a fake Redis (RESP2/RESP3: HELLO/GET/MGET/SET/SETEX/DEL/…), with the real item lifetimes. It drives oslo.cache through its public API. No real memcached or Redis was available; the fakes follow the protocols, so a run against real servers should give the same traffic [inferred].
| # | Scenario | Result |
|---|---|---|
| 1 | memcache_pool: memoized call, miss then hit | get, get, set … 1 0 211, get (hit); key = sha1 of …:get_project|p-pool default |
| 2 | dogpile.cache.memcached: memoized call | same, protocol 0 pickle |
| 3 | dogpile.cache.pymemcache: memoized call | same keys, protocol 5 pickle |
| 4 | cross-library interoperability | 9/9 writer → reader pairs read |
| 5 | backend_expiration_time = 900 | set … 1 900 … |
| 6 | expiration_time = 1 s, 1.2 s pause | value recomputed and rewritten; the server item had no TTL |
| 7 | [svc] cache_time = 1 overrides 600 | recomputed |
| 8 | [svc] caching = false | 2 × (get, get), no set, computed twice |
| 9 | [cache] enabled = false | NullBackend, no traffic |
| 10 | region.invalidate() | no command; the other region still reads v1 |
| 11 | set_multi / get_multi / delete_multi | 2 set, 1 get with 3 keys, 2 delete |
| 12 | non-ASCII key | mangled key = sha1 of the UTF-8 bytes |
| 13 | foreign writer | protocol 2 CachedValue written over a raw socket, read by Python |
| 14 | memcached unreachable (memcache_pool) | miss, value computed, no exception |
| 15 | unreachable, other backends | memcached: miss; pymemcache: ConnectionRefusedError; pymemcache + retry client: miss; Redis: ConnectionError |
| 16 | pool of 1, already borrowed | QueueEmpty after 1 s |
| 17 | key generators | mod:_sample|a 1, mod:_sample|ns|a 1, mod:method|a |
| 18 | Redis | GET, GET, SET <k> <json|pickle>, GET (hit), DEL |
| 19 | Redis + backend_expiration_time | SETEX <k> 900 … |
7. Files in _rewrite/
| File | Content |
|---|---|
scenarios_stable-2026.2.json | the 19 scenarios, outcome and observations |
wire_stable-2026.2.json | the 68 commands received by the fake servers (50 memcached, 18 Redis), with decoded values: pickle protocol, globals, CachedValue |
config-mapping_stable-2026.2.json | 17 [cache] configurations → dogpile arguments or refusal |
8. Recommendation for OpenKeel
- Do not share memoized entries between Go and Python: the keys depend on Python names and
str()formatting (K2, K3), and values are pickles (V6). Give Go services their own key prefix. - Share only through published contracts: keystonemiddleware's token cache, or explicit
deletes. For Keystone during a migration, either run a separate cache, or implement K4 and T5 to take part in region invalidation. - If a Go reader of pickles is still needed: restricted unpickler (V6), and V1–V4, T1–T2.
Source: reverse-spec-oslo.cache/07-cache-contract_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.