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.
Why the payload leaves the broker. A point cloud pushed through the message body is copied at least four times before a worker touches it; a reference is copied once and the cloud is read straight from object storage:
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.
Retries have to be bounded and the chain strictly ordered for the same reason: a fusion step that runs twice, or runs before its predecessor, produces a pose that is wrong in a way no downstream check can distinguish from sensor noise:
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.
Why the ordering constraint is not negotiable #
One design point is worth restating because a task queue makes it easy to lose. The fusion chain is strictly ordered — undistort, then register, then fuse — and the ordering is a data dependency rather than a preference: a register step that runs on an uncompensated cloud produces a transform that is wrong by the ego motion across the sweep, and the fuse step has no way to detect that.
Celery's chain primitive expresses that ordering, and two settings decide whether it holds under failure. Acknowledging late means a worker that dies mid-task returns the message to the queue rather than losing it. Bounding max_retries means a slow-but-alive task cannot be retried indefinitely alongside itself. Together they turn a chain from a hopeful sequence into an ordering that survives a worker restart, which is the condition under which the pipeline actually runs.
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.