3tears Core
Three-tier caching library for Python applications. Provides collections (L1 SQLite -> L2 NATS KV -> L3 PostgreSQL) with subscript access, entity proxy objects, and configurable flush strategies.
Architecture
L1 (SQLite, in-process, sync) -> L2 (NATS KV, shared, async) -> L3 (PostgreSQL, persistent, async)
- L1: In-memory SQLite via WAL mode. Sync access. Used by entity attribute reads and writes.
- L2: NATS KV shared cache. Async. Cross-pod consistency for multi-instance deployments.
- L3: PostgreSQL (or PostGIS, YugabyteDB, etc.). Async. Source of truth.
Reads promote up the stack (L3 miss -> L2 miss -> L1 hit on next access). Writes flow down (L1 -> L2 -> L3, with optional deferred flush).
Quick Start
1. Configure the Registry
from threetears.core.collections.registry import CollectionRegistry
from threetears.core.cache.sqlite import SQLiteBackend
from threetears.nats import Principal, kv_key_scope_for
# Create and configure
l1 = SQLiteBackend("my_app_cache")
l1.initialize(sa_metadata) # SQLAlchemy metadata with your table definitions
registry = CollectionRegistry()
registry.configure(
l1_backend=l1, # SQLiteBackend instance
l2_client=nats_client, # NATS client (optional, None to skip L2)
# REQUIRED wherever an l2_client is: every L2 key is written as
# `{scope}.{table}.{body}`, and the scope is the principal this process
# authenticates to NATS as, so one process cannot read another's cached
# rows. `configure()` raises `L2ScopeNotConfiguredError` without it.
# Derive it -- never a literal -- so it cannot drift from the NATS grant,
# which is minted from the same call.
kv_key_scope=kv_key_scope_for(Principal.AGENT_POD, agent_id=AGENT_ID),
l3_pool=postgres_pool, # asyncpg pool
)
Bind the shared {namespace}-collections bucket once at startup, BEFORE that
configure() call -- one identity declares the bucket and every other process
binds it:
from threetears.core.collections import bind_collections_bucket
await bind_collections_bucket(nats_client, component="my-pod")
2. Per-Collection Pool Overrides
Different collections can use different databases:
# Default: all collections use YugabyteDB
registry.configure(l3_pool=yugabyte_pool)
# Override: geo collection uses PostGIS
registry.configure() # keep defaults
# When creating the collection, register with override:
geo_collection = GeoCollection(registry, config, nats_client, write_buffer)
registry.register(geo_collection, l3_pool=postgis_pool)
3. Define a Collection
from threetears.core.collections.base import BaseCollection
from threetears.core.entities.base import BaseEntity
class UserEntity(BaseEntity):
primary_key_field = "user_id"
class UsersCollection(BaseCollection[UserEntity]):
primary_key_column = "user_id"
@property
def table_name(self) -> str:
return "users"
@property
def entity_class(self) -> type[UserEntity]:
return UserEntity
async def fetch_from_store(self, entity_id):
row = await self.l3_pool.fetchrow(
"SELECT * FROM users WHERE user_id = $1", entity_id
)
return dict(row) if row else None
async def save_to_store(self, data, original_timestamp=None):
# INSERT or UPDATE with optimistic locking
...
async def delete_from_store(self, entity_id):
await self.l3_pool.execute(
"DELETE FROM users WHERE user_id = $1", entity_id
)
def serialize(self, data):
return json.dumps(data, default=str).encode()
def deserialize(self, data):
return json.loads(data)
4. Create Collection Instances
from threetears.core.collections.flush import WriteBuffer
write_buffer = WriteBuffer()
users = UsersCollection(registry, config, nats_client, write_buffer)
The config parameter must satisfy the CoreConfig protocol:
class CoreConfig(Protocol):
collection_flush: str # "ALWAYS", "ON_CHECKPOINT", "ON_SCHEDULE", "ON_SHUTDOWN"
collection_flush_interval: int # seconds between scheduled flushes
collection_flush_tables: str # comma-separated table names eligible for deferred flush
Access Patterns
Subscript Access (sync, transparent pull-through)
Subscript access is the primary API. On L1 miss, data is transparently pulled through L2/L3 via a background event loop. No await needed, no ensure() required:
# Read entity -- pulls through L2/L3 automatically on L1 miss
entity = users[user_id]
# Read single field
name = users[user_id, "name_display"]
# Write single field (writes to L1, tracks for flush)
users[user_id, "name_display"] = "New Name"
# Write full entity data (writes dict to L1)
users[user_id] = {"user_id": user_id, "name_display": "New Name", ...}
# Check if entity is in L1 (does NOT pull through -- L1 only)
if user_id in users:
entity = users[user_id]
__getitem__ raises KeyError only if the entity doesn't exist in any tier. The L1 fast path is ~microseconds; an L1 miss with pull-through adds ~50-200us bridge overhead plus the actual L2/L3 I/O time.
For hot-path code where you want to avoid the sync-async bridge overhead on first access, you can pre-warm L1:
await users.ensure(user_id) # async: pre-warms L1
entity = users[user_id] # guaranteed L1 hit, no bridge needed
Async Operations
# Three-tier read: L1 -> L2 -> L3, promotes on miss. Returns None if not found.
entity = await users.get(user_id)
# Create a new entity (not persisted until save)
entity = users.create({"user_id": uuid7(), "name_display": "Alice", ...})
# Save through three-tier write path (L3 -> L1 -> L2)
await users.save_entity(entity)
# Or via entity directly:
await entity.save()
# Reload from L3 (discards local changes)
await entity.reload()
# Delete from all tiers
await users.delete(user_id)
# Invalidate L1 + L2 (force next read to hit L3)
await users.invalidate_cache(user_id)
Entity Attribute Access
Entities are thin cache proxies. Field data lives in L1, not in the entity object.
entity = await users.get(user_id)
# Read (checks entity._changes first, then L1 cache)
print(entity.name_display)
# Write (writes to L1 + tracks change)
entity.name_display = "Updated Name"
# Check dirty state
entity.is_dirty # True after modification
entity.is_new # True if created via collection.create()
# Get all changes
entity.get_changes() # {"name_display": "Updated Name"}
# Export full entity data from L1
entity.to_dict()
# Persist
await entity.save()
Flush Strategies
Controls when deferred writes reach L3 (PostgreSQL):
| Strategy | Behavior |
|---|---|
ALWAYS |
Every save_entity() writes to L3 immediately |
ON_CHECKPOINT |
Writes buffer to L1 + L2; flushes to L3 on explicit flush_pending() call |
ON_SCHEDULE |
Same as ON_CHECKPOINT but with timer-based auto-flush |
ON_SHUTDOWN |
Writes buffer; flushes to L3 on application shutdown |
Only tables listed in collection_flush_tables are eligible for deferred writes. All other tables always write immediately regardless of strategy.
Bounding L1 Staleness
Off unless a collection asks for it:
registry.set_l1_max_age("my_table", 3600.0) # omit the value for the 3600s default
A read past the bound deletes the L1 row and pulls through, so the next read is fresh. It applies only to rows cached from a lower tier -- a row this process wrote locally is not stamped and never expires, so a field write cannot renew a stale row's lifetime.
Use it for the staleness invalidation cannot reach: a dropped invalidation when the outbound buffer overflows, and a pod whose subscription is partitioned while its peers stay healthy.
A collection with no L3 pool is refused a bound at the point of use.
set_l1_max_age still accepts and stores the value -- the refusal is in
BaseCollection.l1_max_age_seconds, which reports None whatever was
configured, so wiring order cannot defeat it. With nothing to pull through from,
an expired row is not a miss that repairs -- it reads as "this row does not
exist", and a compare-and-set that reads absence writes fresh state over live
state. DuckDBBackend refuses too, with NotImplementedError: it injects no
stamp, so accepting a bound would silently not enforce it.
Optimistic Locking
Collections use date_updated for optimistic locking. When saving an existing entity, the save_to_store implementation should check:
UPDATE users SET ... WHERE user_id = $1 AND date_updated = $2
If rows_affected == 0 for an UPDATE, BaseCollection.save_entity() raises ConcurrentModificationError.
Subclassing Guide
BaseEntity: Set primary_key_field to your PK column name. Add computed properties as needed. Do NOT store data in instance attributes. All data lives in L1.
BaseCollection: Set primary_key_column. Implement the 5 abstract methods: fetch_from_store, save_to_store, delete_from_store, serialize, deserialize. Use self.l3_pool for database access. Add domain-specific query methods (e.g., find_by_email).
Release files for 3tears 0.39.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| 3tears-0.39.0.tar.gz | 588.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 3tears-0.39.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 963.2 kB
Release files / 3tears-0.39.0.tar.gz
| Download URL | 3tears-0.39.0.tar.gz |
|---|---|
| Size | 588.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6e592f23bf0cd85a6c6c6069cb9feff1f70aa3b19f31e67dd1d78c4815f7a0b0
|
|
BLAKE2b-256 checksum How to use checksums |
d32a52f88170e1f7d1e254c2520cd549e960e249ab22da728dd8592aaacbec83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.
Transparency logRelease files / 3tears-0.39.0-py3-none-any.whl
| Download URL | 3tears-0.39.0-py3-none-any.whl |
|---|---|
| Size | 374.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6d4367522e9e57a34adf4663ea60d4d4b3295909fa239dde91d3db3b6e2192f3
|
|
BLAKE2b-256 checksum How to use checksums |
dda3c2cf7449fe1a4d000b8bf7bed079a259f3ada4b6de96339e981ebedf276d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.
Transparency log