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 come from the
filelockpackage (a runtime dependency), using a sidecar lock file, so they work cross-platform and across processes — not just across threads. Default acquisition timeout is 3 seconds; exceeding it produces an error dict 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=Truewrites to<file>.tmpand thenos.replace()s it into position (retried up to 10 times), so a reader never observes a partially written file, and a crash mid-write cannot truncate the original.Locks are released in a
finally:block, and stale lock files are cleaned up explicitly (filelockdoes not always do so on Linux).
The same mechanism protects <CMETA_HOME>/index/*.pkl, repos.json, and each
artifact’s _cmeta.yaml / _cmeta.json.
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.
What is not concurrency-safe
safe_read_file_via_cache()— the in-memory cache dict it maintains is explicitly documented as not thread-safe. It is the fast read path for the index. This is precisely whyCMetaAsyncuses 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 an error, not corruption. 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. Avoid running
cx --reindexwhile other processes are writing to the same home.
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