Skip to content

Signal-Driven Sync

The storefront reads products from the Matrix read model, so an edit in PIM, a new price or a stock change is invisible until the read model is rebuilt. Signal-driven sync does the rebuild for the affected SKUs: a signal handler puts sku:channel_idx into a Redis sorted set, a debounce lock collects rapid edits, and one Celery task dispatches the rebuilds. The PIM and price handlers are off until enabled; the QMS handler has no switch.

Diagram

PIM, in django_pim/signals/handlers.py:

ModelSignalWhat is enqueued
Productpost_save, post_deletereal_product.sku and shop.idx of the product
ProductAttributepost_save, post_deletethe product of the row
RealProductpost_saveevery Product of that real product, one per channel
Attributepost_saveevery product that uses the attribute
Featurepost_saveevery product whose feature set contains the feature
ProductPicturepost_save, post_deletethe product of the row
ProductVideopost_save, post_deletethe product of the row
ProductInCategorypost_save, post_deletethe product of the row

The price manager, in django_pricemanager/signals/handlers.py:

ModelSignalWhat is enqueued
CurrentPricepost_save, post_deleteproduct.sku and channel.idx of the price

QMS, in django_qms/signals/handlers.py:

SignalSent whenWhat is enqueued
warehouse_stock_changed (a QMS signal, not a model signal)a stock batch created or changed at least one warehouse stock recordevery SKU of the batch, for every channel of the warehouse

All three write to the same sorted set and the same lock, so one flush task serves them.

The category tree is not part of the read model. The Matrix category endpoint reads PIM’s ProductCategory table directly, so a renamed category needs no signal. Assigning a product to a category does: that is ProductInCategory above.

Each source has its own switches.

A PIM handler does nothing when any of these holds:

  • The toggle is off. PimSettings.matrix_signals_enabled is a boolean on the singleton PimSettings model, False by default. Its value is cached for 60 seconds; saving it in the Django admin clears the cache.

  • The code suppressed it. A thread-local context manager, for code that calls save() in a loop:

    from django_pim.signals.killswitch import suppress_matrix_signals
    with suppress_matrix_signals():
    for product in products:
    product.save()

    Suppression in one thread does not affect another.

  • The channel is on the denylist. PIM_MATRIX_SIGNALS_CHANNEL_DENYLIST in the Django settings holds channel idx values.

  • The save comes from a fixture. Handlers skip raw saves, so loaddata enqueues nothing.

The same three layers under its own names: PriceManagerSettings.matrix_signals_enabled (False by default, cached for 60 seconds, cleared when the row is saved), suppress_price_matrix_signals() from django_pricemanager.signals.killswitch, and PRICEMANAGER_MATRIX_SIGNALS_CHANNEL_DENYLIST.

No toggle, no suppression and no denylist. The handler enqueues for every active warehouse when django_pim is installed and enabled, and skips inactive warehouses.

Every enqueue adds sku:channel_idx to the sorted set pim:matrix:pending with the current time as its score. The same pair written twice is one entry.

The handler then tries to set pim:matrix:flush_scheduled if it is absent, with a lifetime of the debounce window plus one second. The handler that gets the lock sends django_matrix.tasks.flush_pending_matrix_sync to the matrix_pull queue with a countdown equal to the debounce window. Every other handler in that window only adds to the set.

The flush task reads and deletes the set in one Redis pipeline, drops the lock and groups the entries by channel. A channel with more pending SKUs than PIM_MATRIX_SIGNALS_BATCH_THRESHOLD gets one fill_read_model_for_channel_task; any other channel gets one fill_read_model_for_product_task per SKU.

The Attribute and Feature handlers count the affected products first, across all channels. Above the same threshold they enqueue the marker __CHANNEL_REBUILD__ for each affected channel instead of every SKU, and the flush task turns the marker into a channel rebuild.

SettingDefaultMeaning
PIM_MATRIX_SIGNALS_DEBOUNCE_SECONDS5debounce window of the PIM and QMS handlers
PIM_MATRIX_SIGNALS_BATCH_THRESHOLD500pending SKUs per channel above which the channel is rebuilt as a whole
PIM_MATRIX_SIGNALS_CHANNEL_DENYLIST[]channel idx values the PIM handlers skip
PRICEMANAGER_MATRIX_SIGNALS_DEBOUNCE_SECONDS5debounce window of the price handlers
PRICEMANAGER_MATRIX_SIGNALS_CHANNEL_DENYLIST()channel idx values the price handlers skip

The PIM and price toggles are off by default, so on a fresh installation only stock changes from QMS are enqueued.

  1. Run a Celery worker that consumes the matrix_pull queue. In the entirius-zeno harness the worker takes its queues from the recipe, and the default recipe lists matrix_pull.
  2. In the Django admin, open the PIM settings (/admin/django_pim/pimsettings/), set Matrix signals enabled and save. Do the same in the price manager settings for price changes.
  3. Check with python manage.py matrix-sync-status: the first line reads matrix_signals_enabled: True.

bulk_create() and bulk_update() send no post_save, so an import built on them enqueues nothing. An import that calls save() per row runs inside suppress_matrix_signals(), shown under Kill Switches. Either way the read model is rebuilt once at the end:

from django_matrix.tasks import fill_read_model_with_flags
fill_read_model_with_flags(channel_idx=channel_idx)

The enqueue code logs through the standard logging module. PIM’s, which QMS calls too, uses the logger django_pim.signals and writes:

Flush scheduled in 5s, enqueued SKU=<sku> channel=<channel_idx>
Flush already scheduled, enqueued SKU=<sku> channel=<channel_idx>
Cascade threshold exceeded, enqueued channel rebuild for channel=<channel_idx>
Redis unavailable, skipping signal sync for SKU=<sku> channel=<channel_idx>
Redis error during enqueue for SKU=<sku> channel=<channel_idx>

The second line is logged at debug level, the last two as warnings. The price manager logs under django_pricemanager.signals.dispatch with its own wording, such as Price sync flush scheduled in 5s, enqueued <sku>:<channel_idx>.

The flush task and the channel rebuild task log through ProcessLogger("matrix.signal_sync") with two codes:

CodeMeaningExample message
5320flush and channel rebuildFlush complete: 47 pending entries, 47 tasks dispatched
5329errorRedis unavailable during flush: <error>, Flush failed: <error>

The per-product task logs through ProcessLogger("matrix.fill_read_model") with code 5200, as a manual rebuild of one product does.

Terminal window
python manage.py matrix-sync-status

It prints the PIM toggle, the three PIM settings, the number of pending entries with the first twenty, whether a flush is scheduled, how long ago the last flush ran, and the number of active and reserved Matrix tasks that the Celery workers report.

KeyTypeLifetimeHolds
pim:matrix:pendingsorted set300 spending sku:channel_idx entries, score is the time
pim:matrix:flush_scheduledstringdebounce window + 1 sthe lock that keeps one flush per window
pim:matrix:last_flushstring86400 stime of the last flush

The two toggles sit in the Django cache under pim:matrix_signals_enabled and pricemanager:matrix_signals_enabled for 60 seconds.

Matrix copies picture and thumbnail references from PIM during a rebuild and generates no thumbnails. A picture that has no thumbnails yet enters the read model with its original image only. Generate them with pim-thumbs-generate and rebuild the product.

  1. python manage.py matrix-sync-status: is matrix_signals_enabled true, are entries pending, is a flush scheduled.
  2. A worker consumes the matrix_pull queue.
  3. The channel is not on a denylist of PIM or the price manager.
  4. The worker log has no line with code 5329 and no failed fill_read_model_for_product_task.
  • A bulk operation calls save() per row without suppress_matrix_signals().
  • No worker consumes matrix_pull, so the flush task never runs. The set expires 300 seconds after the last enqueue and those changes are lost to the signal path: rebuild with fill-read-model.

The enqueue code catches every exception, logs a warning and returns, so the save in PIM or the price manager succeeds and nothing is synced. The flush task retries up to three times, 30 seconds apart. fill-read-model does not use the pending set and works without it.

Thumbnails on upload: the PIM picture upload endpoint does not generate thumbnails today, which is the limitation above.

One product followed from the catalog through the read model to an order in the CMS: Commerce Journey.