Building Async Sensor Fusion Pipelines with Celery
Configure Celery to orchestrate non-blocking sensor fusion tasks — ingesting LiDAR sweeps, synchronized camera tensors, and GNSS/IMU odometry — without coupling worker memory or latency to the vehicle's primary control loop. This step assembles the distributed task layer of an asynchronous data pipeline architecture so that gigabyte-scale point clouds never transit the broker, workers recycle deterministically after heavy registration jobs, and hardware timestamps propagate intact through a strictly ordered fusion chain.
Celery's out-of-the-box configuration is fundamentally misaligned with this workload: its default pickle serializer, persistent worker processes, and broker-resident payloads all break down once a single task carries a multi-million-point cloud. The result is broker frame-size violations, heap fragmentation from long-lived NumPy and Eigen buffers, and blocking AsyncResult.get() calls that stall the worker. The configuration below corrects each of these for a production sensor fusion deployment.
Out-of-band transport keeps gigabyte payloads off the broker; only URIs flow through the task chain:
Prerequisites #
- Python 3.10+ with
celery==5.3.6,msgpack==1.0.8,redis==5.0.4(result backend), andopen3d==0.18.0for the registration payloads. - Broker: RabbitMQ 3.13 or Redis 7.2. Examples assume RabbitMQ for task routing and Redis for results.
- Shared payload store: either a
tmpfsmount at/dev/shm(single-node) or a MinIO/S3 bucket (multi-node). - Upstream stage: an ingestion driver that already emits hardware-level timestamps in nanoseconds and writes raw
sensor_msgs/PointCloud2and image frames to the payload store. Timestamp discipline itself is covered in deterministic LiDAR and camera timestamp alignment in ROS; this page assumes those stamps are trustworthy. - Downstream consumer: the registration stage from production-grade ICP registration in Python, invoked inside
coordinate_transformandspatial_fusion.
Step-by-step #
1. Configure the worker pool for native numerical workloads #
Spatial libraries (Open3D, PDAL, PCL bindings) release the GIL and rely on C-level threading, which conflicts with the cooperative gevent/eventlet runtimes. Force prefork for OS-level process isolation, and recycle each worker after a single heavy task so fragmented heaps from Eigen/NumPy buffers are returned to the OS.
# celeryconfig.py
broker_url = "amqp://av:av@rabbitmq:5672//"
result_backend = "redis://redis:6379/0"
# Native numerical workloads — prefork only, no cooperative concurrency.
worker_pool = "prefork"
worker_concurrency = 4
# Recycle after every heavy registration job to defeat heap fragmentation.
worker_max_tasks_per_child = 1
worker_prefetch_multiplier = 1 # one in-flight task per worker; no greedy prefetch
worker_prefetch_multiplier = 1 prevents a worker from reserving a queue of multi-gigabyte tasks it cannot hold in RAM simultaneously. Expected effect: each worker handles exactly one fusion job, then exits and is respawned with a clean address space.
2. Replace pickle with msgpack and move payloads out-of-band #
pickle is slow and an arbitrary-code-execution surface; raw point clouds must never traverse the broker at all. Serialize only metadata and URIs with msgpack, and write binary payloads to the shared store.
# celeryconfig.py (continued)
task_serializer = "msgpack"
result_serializer = "msgpack"
accept_content = ["msgpack", "json"]
# Cap broker frame size so an accidental large payload fails fast, loudly.
broker_transport_options = {"max_message_bytes": 262_144} # 256 KiB
# payload_io.py — write binary, pass an immutable URI through the chain
import uuid, numpy as np, open3d as o3d
PAYLOAD_ROOT = "/dev/shm/fusion"
def stash_cloud(cloud: o3d.geometry.PointCloud) -> str:
"""Persist a cloud to shared memory, return a URI for task kwargs."""
uri = f"{PAYLOAD_ROOT}/{uuid.uuid4().hex}.npy"
np.save(uri, np.asarray(cloud.points, dtype=np.float32))
return uri
def load_cloud(uri: str) -> o3d.geometry.PointCloud:
pts = np.load(uri, mmap_mode="r") # memory-mapped, zero-copy read
pc = o3d.geometry.PointCloud()
pc.points = o3d.utility.Vector3dVector(np.asarray(pts, dtype=np.float64))
return pc
Switching coordinate and calibration matrices to msgpack cuts serialization latency by roughly 40–60% versus pickle; the 256 KiB broker frame cap guarantees that any payload mistakenly passed inline raises a kombu frame error instead of silently bloating the broker.
3. Build the strictly ordered fusion chain with bound retries #
Temporal synchronization must precede spatial alignment. Define idempotent tasks bound with retries and exponential backoff, then wire them into a chain so order is enforced regardless of arrival jitter. Pass hardware timestamps and payload URIs as immutable kwargs — never the binary data.
# tasks.py
from celery import Celery
from celery.utils.log import get_task_logger
from payload_io import load_cloud, stash_cloud
app = Celery("fusion")
app.config_from_object("celeryconfig")
log = get_task_logger(__name__)
@app.task(bind=True, max_retries=3, retry_backoff=True, acks_late=True)
def temporal_sync(self, *, cloud_uri, image_uri, t_lidar_ns, t_cam_ns):
drift_ns = abs(t_lidar_ns - t_cam_ns)
if drift_ns > 8_000_000: # 8 ms hard drift bound
raise self.retry(exc=ValueError(f"drift {drift_ns} ns exceeds 8 ms"))
return {"cloud_uri": cloud_uri, "image_uri": image_uri,
"t_ref_ns": min(t_lidar_ns, t_cam_ns)}
@app.task(bind=True, max_retries=3, retry_backoff=True, acks_late=True)
def coordinate_transform(self, synced, *, extrinsics_uri):
cloud = load_cloud(synced["cloud_uri"]) # mmap fetch by URI
# apply ISO 8855 vehicle-frame extrinsics here...
synced["cloud_uri"] = stash_cloud(cloud)
return synced
@app.task(bind=True, max_retries=3, retry_backoff=True, acks_late=True)
def spatial_fusion(self, synced):
cloud = load_cloud(synced["cloud_uri"]) # ICP / fusion runs here
tile_uri = stash_cloud(cloud)
return {"tile_uri": tile_uri, "t_ref_ns": synced["t_ref_ns"]}
# dispatch.py — order is guaranteed by the chain, not by arrival time
from celery import chain
from tasks import temporal_sync, coordinate_transform, spatial_fusion
def submit_fusion(cloud_uri, image_uri, extrinsics_uri, t_lidar_ns, t_cam_ns):
workflow = chain(
temporal_sync.s(cloud_uri=cloud_uri, image_uri=image_uri,
t_lidar_ns=t_lidar_ns, t_cam_ns=t_cam_ns),
coordinate_transform.s(extrinsics_uri=extrinsics_uri),
spatial_fusion.s(),
)
return workflow.apply_async() # returns AsyncResult, non-blocking
acks_late=True keeps a task on the queue until it actually completes, so a worker killed mid-registration redelivers rather than losing the frame. apply_async() returns immediately — the dispatcher never blocks the ingestion loop.
4. Emit spatial telemetry into structured logs #
Embed spatial bounds and frame identity into every log line so a stalled tile can be traced to its sensor and timestamp. Inject custom fields into Celery's task log format and route worker metrics to Prometheus.
# celeryconfig.py (continued)
task_log_format = (
"%(asctime)s %(levelname)s %(task_id)s %(task_name)s "
"queue=%(processName)s host=%(hostname)s :: %(message)s"
)
worker_send_task_events = True # enable celery-exporter / Flower metrics
task_send_sent_event = True
# inside spatial_fusion, before returning:
log.info("fused tile bbox_wgs84=%s frame_ts_ns=%d sensor_id=%s",
bbox_wgs84, synced["t_ref_ns"], sensor_id)
Scrape task_latency, queue_depth, and worker_memory_rss from celery-exporter to catch broker backpressure and memory pressure before they cascade.
Verification & acceptance criteria #
Run a soak test that submits 500 fusion chains and confirm each invariant:
# verify.py
import psutil, time
from dispatch import submit_fusion
results = [submit_fusion(*frame) for frame in load_test_frames(500)]
for r in results:
out = r.get(timeout=30) # acceptable in the TEST harness only
assert out["tile_uri"].endswith(".npy")
assert r.state == "SUCCESS"
# Memory must return to baseline — no monotonic RSS growth across the run.
rss_mb = psutil.Process().memory_info().rss / 1e6
assert rss_mb < 1500, f"worker leak suspected: {rss_mb:.0f} MB"
Acceptance gates:
- Broker isolation:
rabbitmqctl list_queues name message_bytesreports per-message bytes ≤ 256 KiB — no point-cloud binaries on the broker. - Memory recycling: worker
memory_rssreturns to baseline between tasks; the 500-frame run shows no monotonic upward RSS trend. - Temporal coherence: every accepted chain has
drift_ns ≤ 8_000_000(8 ms); frames exceeding it appear in the retry/dead-letter path, not in output. - Throughput: dispatch (
apply_async) latency stays ≤ 5 ms, confirming the ingestion loop never blocks on Celery.
Common errors & fixes #
kombu.exceptions.OperationalError: Message exceeds maximum size — a raw cloud was passed as a kwarg instead of a URI. Confirm the producer calls stash_cloud() and that task kwargs contain only strings and scalars. The 256 KiB max_message_bytes cap is doing its job; do not raise it to "fix" this.
Silent RSS growth across tasks (OOM-killed workers after hours) — worker_max_tasks_per_child is unset or gevent/eventlet is active, so fragmented Eigen/NumPy heaps are never reclaimed. Set worker_max_tasks_per_child = 1 and worker_pool = "prefork", then re-run the soak test.
Can't decode content-type 'application/x-python-serialize' — a producer is still emitting pickle while consumers accept only msgpack. Pin task_serializer, result_serializer, and accept_content identically across every process and redeploy them together.
Workers stall and queue_depth climbs without throughput — a blocking AsyncResult.get() is being called inside a task, deadlocking the prefork worker. Replace in-task .get() with a chain/chord so dependencies resolve through the broker; reserve .get() for the dispatcher or test harness only.
Related #
- Deterministic temporal alignment of LiDAR and camera streams in ROS
- Production-grade Iterative Closest Point (ICP) registration in Python
- Handling coordinate drift in multi-sensor setups
Up one level: Asynchronous Data Pipeline Architecture for HD mapping and spatial processing.