Async and concurrency
cMeta can be driven serially — one call at a time from a script or the CLI — or asynchronously, from an event loop inside FastAPI or any other asyncio-based server. The same categories, artifacts and commands work in both modes; only the way you enter the framework changes.
A deliberate goal of cMeta, and a change from its predecessors, is that
concurrent execution is a supported mode rather than an accident. The index
and the artifact files are protected by explicit safety guards (§4), so several
processes can create, read and update artifacts in the same <CMETA_HOME> at
the same time without corrupting it.
1. Two ways to run
Serial |
Async |
|
|---|---|---|
Class |
|
|
Call |
|
|
Blocks the event loop? |
yes |
no |
Where the work happens |
the calling process |
a worker process from a pool |
Use for |
scripts, CLI, CI, tests, agent tools |
FastAPI / asyncio servers |
Install:
pip install cmeta # serial use — everything below except CMetaAsync's host
pip install "cmeta[server]" # + FastAPI, uvicorn, jinja2, starlette (pulls cmeta[async])
pip install "cmeta[all]" # dev + async + server
CMetaAsync itself needs nothing beyond the base install — it is built on
asyncio and concurrent.futures from the standard library. The [async]
extra exists only so that cmeta[server] can reference it; the [server]
extra is what pulls in the web stack.
2. Serial use
Nothing special — this is the default:
from cmeta import CMeta
cm = CMeta()
r = cm.access({'category': 'repo', 'command': 'list'})
if cm.catch_error(r): raise RuntimeError(r['error'])
Use a plain CMeta for scripts, CI steps, tests and agent tools. It is also
what you want inside a worker process that is already running in parallel.
3. Async use (CMetaAsync)
import asyncio
from cmeta.core_async import CMetaAsync
async def main():
cm = CMetaAsync(max_workers=4)
r = await cm.access({'category': 'repo', 'command': 'list'})
if r['return'] > 0:
print('error:', r['error'])
cm.shutdown()
if __name__ == "__main__": # required on Windows/macOS (spawn)
asyncio.run(main())
CMetaAsync subclasses CMeta and overrides access() with an async
version. Each call is dispatched to a ProcessPoolExecutor worker, so the
blocking filesystem work never runs on the event loop. Each worker process
lazily builds one persistent CMeta instance and reuses it for every
subsequent call, so the initialisation cost is paid once per worker, not once
per request.
Constructor arguments:
Argument |
Meaning |
|---|---|
|
Size of the process pool. |
|
Custom logger; defaults to the inherited |
|
Event loop to use; defaults to the current one. |
|
Anything |
Always call cm.shutdown() (it waits for pending work) when the application
stops.
Errors cross the process boundary unchanged
The usual contract holds — you get the same dict, with the same codes, that a
serial call would return, including the soft 16:
r = await cm.access({'category': 'note', 'command': 'find', 'arg1': 'nope'})
# -> {'return': 16, 'error': 'note "nope" not found'}
If the dispatch itself fails (a worker dies, the payload cannot be pickled),
CMetaAsync.access() returns {'return': 99, 'error': 'CMetaAsync internal error: ...'} with the traceback included. See
error-handling.md.
Running calls in parallel
Because each call goes to its own worker, ordinary asyncio fan-out gives you real parallelism:
categories = ['repo', 'config', 'app', 'utils', 'note', 'script']
results = await asyncio.gather(*[
cm.access({'category': 'category', 'command': 'find', 'arg1': c})
for c in categories
])
for c, r in zip(categories, results):
print(c, r['return'])
Use asyncio.gather(..., return_exceptions=True) if you would rather collect
failures than let the first one abort the group. To bound how much runs at
once independently of max_workers, wrap calls in an asyncio.Semaphore.
access_sync() — know the limitation
CMetaAsync also exposes access_sync(), intended for code already running
inside a worker where blocking is fine.
Caveat:
access_sync()callsCMeta.access()withselfstill bound to the async instance. Commands whoseapi/v1.pyre-enters the framework viaself.cm.access(...)therefore hit the async override and hand back an un-awaited coroutine instead of a result dict.cx repo listis one such command; simple commands that never re-enter (e.g.category find,utils uid) do return a dict.Don’t rely on
access_sync()on aCMetaAsyncobject. Useawait cm.access(...)on the event loop, and construct a plainCMeta()where you genuinely need synchronous calls.
Who uses this
CMetaAsync is not a demo path — it is what runs cMeta’s web front-ends:
The shipped
cserverapp (cmeta/internal-repo/app/cserver/src/app.py) creates a singleCMetaAsyncat import time, sized from the CPU count, andawaits it in every handler. Run it withcx app run cserverto see the pattern working locally — it is the reference implementation.The cTuning.ai platform (
ctuning.server) is built the same way, usingawait cm.access(...)throughout its FastAPI application — user accounts, logging, repository search and project pages all reach cMeta through the async interface.
FastAPI
The shipped cserver app is the reference example — create one CMetaAsync at
import time, sized from the CPU count, and await it in the handlers:
import os
from fastapi import FastAPI
from cmeta.core_async import CMetaAsync
app = FastAPI()
max_workers = int(os.cpu_count() * 0.8 + 0.5)
cm = CMetaAsync(max_workers=max_workers)
@app.get("/repos")
async def repos():
r = await cm.access({'category': 'repo', 'command': 'list'})
if r['return'] > 0:
return {'error': r['error']}
return {'repos': r.get('artifacts', [])}
@app.on_event("shutdown")
async def _shutdown():
cm.shutdown()
Run it with cx app run cserver (or cserver) to see the pattern working.
The cTuning.ai platform follows the same shape at a larger scale.
4. Concurrency safety guards
Several processes may share one <CMETA_HOME> — parallel CI jobs, a pool of
async workers, a server plus your terminal. The framework protects the two
things that would otherwise corrupt: the fast index and the artifact
metadata files.
Locking + atomic writes
Anything that mutates a shared file follows the same read-modify-write cycle, with the lock held across the whole cycle:
r = utils.files.safe_read_file(index_file, lock=True, keep_locked=True, ...)
# ... modify the data ...
r = utils.files.safe_write_file(index_file, index_data,
file_lock=index_file_lock, atomic=True, ...)
File locks are the engine’s own
cmeta.utils.files.PathLock(no library), using the sidecar file<file>.lock, so they work cross-platform, across processes and across the threads of one process. A writer waits up to 30 seconds for a busy lock (CMETA_LOCK_TIMEOUTchanges it), prints acMeta warning:line on stdout after 3 seconds naming the holder when it left a note in the lock file, and then gives up with an error dict that names the holder rather than a corrupt file.keep_locked=Truehands the lock back with the data so the caller can modify and write without ever dropping it — this is what prevents lost updates.Atomic writes: a JSON, YAML or pickle file is written to
<file>.tmpand thenos.replace()d into position (the default ofsafe_write_filesince 0.34.1; text is written in place), so a reader never observes a partially written file, and a crash mid-write leaves the previous file and no temporary. The mode of the target is kept, a symbolic link is written through, a read-only target is refused, and the replace is retried while a reader holds the target open (Windows) until the lock timeout.Locks are released in a
finally:block, and the lock file is removed by the process that releases it, so repositories never keep lock files. A crash leaves at most the lock file of the operation in flight; it blocks nobody (the OS lock dies with the process) and the next holder reuses and removes it.
The same mechanism protects <CMETA_HOME>/index/*.pkl, repos.json (also
while the incremental index of plug / unplug / repo reindex reads and
rewrites it), and each artifact’s _cmeta.yaml / _cmeta.json, including the
one a create writes.
How the lock works on each platform
A lock file that is removed after use is correct only if the holder removes it
before releasing the OS lock and every newcomer checks, after acquiring, that
the file it holds is still the file at the path (otherwise a waiter blocked on
the removed file and a newcomer that created a new one both “hold” the lock).
PathLock does both. On Windows it takes a byte-range lock
(msvcrt.locking) on the lock file; an open file cannot be removed there, so a
newcomer that meets a file being deleted simply tries again. On POSIX it
takes fcntl.flock (per open file description, so threads exclude each other
too), or POSIX record locks (fcntl.lockf) where the file system refuses
flock (NFS and some network mounts), each with the identity check. Where the
file system refuses both (some FUSE, 9p and SMB mounts) it falls back to a
soft lock: the file is created exclusively and holds pid host time system. The holder keeps the file open and rewrites the time in its line
every 2 seconds (the heartbeat: a daemon thread, the line rewritten in place
so the file is never empty). A stale lock file - its pid dead on this system,
or its line unchanged for 20 seconds on the waiter’s own monotonic clock (the
holder is on another system or host, or stopped beating) - is taken over by one
of the waiters that find it. Nothing is read from the clock of the drive or of
another machine (a network share, a Windows drive inside WSL2 keep other
clocks): the system word (a short hash of the kernel’s boot id and the pid
namespace on Linux, of the machine id on Windows and macOS) tells the waiter
whether the pid is one it can check - Windows and WSL2 share the host name and
have different pids, a container shares the kernel of its host and has its own
pids. An empty lock file, which nothing dates, is a leftover once a waiter has
seen it empty for 2 seconds. The waiter does not remove the stale file: it
makes its own file exclusively under a name that belongs to that stale file
(<lock file>.take-<checksum>, so one waiter only gets to make it), checks
that the file at the path is still the one it judged, and puts its own in its
place with one rename - the path is never free in between. A holder checks
that the file at the path is its own after it took the lock, and removes it at
release only when it still is; a forked child never releases its parent’s lock.
A lock file written by cMeta before 0.35.0 (three words, no heartbeat) is
judged as it was then: the pid when the host name is this one, else its age
against 10 minutes. In every mode the threads of one process are serialized on
the path first. A busy lock is polled with a delay that doubles from 1 ms to
25 ms until the timeout.
A folder written by several systems at once - a Windows tree plugged from
WSL2 or from a container through a bind mount, a network share mounted on two
machines - has no lock between those systems with the OS locks: each system’s
lock is invisible to the others (measured on one folder of a Windows drive seen
by Windows, WSL2 and a container: no exclusion in any direction, while an
exclusive create is honoured by all). Such a home or repository asks for the
soft lock: CMETA_SOFT_LOCK_PATHS=<folder>[<os.pathsep><folder>...] (*:
everywhere) names folders for a process, lock: soft in a repository’s
_cmr.yaml names the repository for every system that plugs it (the engine
registers it when the repository is plugged and at the start of every process,
from the index of repositories; utils.files.use_soft_lock_under is the API).
With it, the systems exclude each other through the exclusive create, a holder
that dies on one system is replaced from another after the 20 seconds of
silence, and a live holder is never taken over (on Windows its open file cannot
even be replaced). Everything else keeps the OS locks at the same cost. One
thing the lock does not change: every system keeps its own index of the
repository in its own home, and a find answers from that index - what another
system created or changed in the shared repository is seen here after
cx repo reindex <alias> (or cx <category> reindex <artifact>); the files
in the repository are always the current ones, and every update reads and
rewrites the artifact’s file under the lock, so no update is lost.
The index lock
A full reindex (cx --reindex, or the first run on a home without an index)
builds the new index in index.tmp-<pid> and swaps it in when it is complete.
So that a record written meanwhile is not lost in the swap, the lock file
<CMETA_HOME>/index.lock (the sidecar of the index folder, kept through the
swap) is held by the full reindex for its whole rebuild and swap, by the
incremental index of pull / plug / repo reindex for its duration, and by
every write of a record (create, update, delete, reindex of an artifact)
for a moment. A writer that arrives during a reindex waits for it, prints a
cMeta warning: line on stdout after 3 seconds naming the reindexing process (which leaves a
note in the lock file), and gives up with an error after CMETA_INDEX_LOCK_TIMEOUT
seconds (600 by default); otherwise it then writes into the new index. Two
reindexes run one after the other; several processes starting on a fresh home
at once build one index. Readers never take the lock, so lookups cost what they
did; a write costs one more lock acquisition (microseconds on Linux, a fraction
of a millisecond on Windows). Under the same lock a reindex removes the
index.tmp-* / index.old-* leftovers of killed rebuilds.
A create under the index lock
create takes the index lock before it makes the folder, writes the meta and
adds the record, and releases it after (Repos.lock_index,
add_to_index(index_lock=...)): a full reindex never sees a half-made
artifact, several creates of one artifact at once end with one artifact and
“already exists” errors (the second finds the folder of the first), and a
create whose record cannot be written (the lock of its index file stayed held)
removes the folder it made, so nothing is left unindexed.
Measured behaviour
Ten cx note add processes run simultaneously against one <CMETA_HOME> all
exit 0, and both the index and the directory listing show all ten artifacts.
Eight processes updating the same artifact’s metadata at the same time also
all exit 0, and all eight fields are present afterwards — no lost
updates, and no stale .lock files left behind. The lock’s own tests
(tests/core_tests/test_utils_files_path_lock.py) run eight processes and
sixteen threads through one counter, replay the removed-file race
deterministically, kill a holder, and exercise the soft lock and its stale
files - with a drive whose clock is off in both directions, and with six
processes that keep taking over the lock files of “crashed” holders side by
side without ever being two inside the lock; tests/benchmarks/benchmark_engine.py
measures the cost.
What is not concurrency-safe
safe_read_file_via_cache()keeps the in-memory cache of the index perCMetainstance, keyed by the file’s modification time in nanoseconds, size and inode; an index file read before that is missing for a moment (a replace on a mount whose rename is not atomic, the swap of a full reindex) is retried a few times before “not found”; its dict is updated under a lock, andfindhands out one-level copies of the records it returns (the record, itscmetaand itstags), so a caller may edit those. Anything deeper, and what the lower-levelfind_in_indexreturns, is shared with the cache and must be treated as read-only.CMetaAsyncstill uses processes, not threads: each worker gets its ownCMetaand its own cache.Do not share one
CMetainstance across threads. Give each thread its own instance, or useCMetaAsyncand let the process pool isolate them.Long-running external work started by a task (builds, downloads) is not automatically serialised. Content-addressed
cacheentries make repeated work cheap, but two processes asked to build the same thing at the same time can both build it.
Practical notes
Lock contention shows up as a wait, then an error, not corruption. A writer waits up to
CMETA_LOCK_TIMEOUTseconds (30) for the lock of a file and says so (acMeta warning:line on stdout) after 3. If you drive very heavy parallel updates of the same artifact, expect occasional lock timeouts; retry the call.Separate
<CMETA_HOME>s remove sharing entirely. For fully independent parallel jobs, give each one its own home (--home=<path>orCMETA_HOME) — see using-cmeta.md §7.4.Reindex is a whole-home operation. Writes made while
cx --reindexruns wait for it (see The index lock above) and land in the new index; a very long reindex (a huge home on a slow disk) makes them wait as long, up toCMETA_INDEX_LOCK_TIMEOUTseconds.
5. Choosing a mode
A script, a CI step, a test, an agent tool →
CMeta, serial.A FastAPI/asyncio server →
CMetaAsync, one instance,awaitevery call.Parallel independent jobs → several processes, each with its own
CMeta; share a<CMETA_HOME>only if they need to see each other’s artifacts.Threads → give each thread its own
CMeta, or preferCMetaAsync.
Source of truth: cmeta/core_async.py (CMetaAsync, _get_cmeta,
_access_worker), cmeta/utils/files.py (_acquire_lock, safe_read_file,
safe_write_file), cmeta/repos.py (index updates), and
cmeta/internal-repo/app/cserver/src/app.py (working FastAPI example).
Related: installation.md · error-handling.md · documentation index