[FROSTLABS] · home / writing / write cascade
2026-05-24 · 7-min read · Odoo · Marketplace connector · ORM

The hidden write-cascade in marketplace_base.product_template.write().

Multi-marketplace Odoo connectors live or die by what they do inside product.template.write(). Override it naively (fire a re-sync to every connected channel on every write) and a normal product-page save takes 45 seconds, blocks the user, and leaves half-applied marketplace state if any channel returns an error. Override it correctly (change-aware diff, async dispatch, per-channel idempotency) and the same save returns in 80ms. Here's the pattern and the four traps it avoids.

$The naive override

Walk through the obvious shape a connector author writes the first time. The product page in Odoo backend fires write() on the product.template model whenever a user saves. To keep marketplace listings synced, the connector overrides:

# Naive. Looks reasonable, isn't.
class ProductTemplate(models.Model):
    _inherit = "product.template"

    def write(self, vals):
        result = super().write(vals)
        for product in self:
            for listing in product.marketplace_listing_ids:
                listing.push_to_channel()
        return result

This compiles. It even works on a single-channel test environment with one product. In production, it goes wrong in four ways simultaneously.

$Trap 1: fires on every write, including writes you don't make

Odoo writes to product.template far more often than user-driven edits. Image derivative compute fires write(). Stock-quantity recompute fires write() (technically on product.product, but its write() often touches the template). The last_synced_at field on a connector's own model frequently triggers a parent-record touch. Onboarding a new module that adds a field fires write() on every product when the module installs.

The naive override fires the full marketplace re-sync on every one of these. A module install on a 50,000-product catalog → 50,000 × N-channels API calls to remote marketplaces. The Amazon SP-API rate limit is 5 req/s. The math: 50,000 × 4 channels / 5 req/s = 40,000 seconds, ~11 hours, of API calls. The first 30 minutes of those calls return 429s; the next 10 hours the connector loops on retries, slowly thrashing.

I've debugged exactly this on two clients. Both had unexplained "the catalog goes red on Mondays" patterns. Mondays being when a module developer's CI deployed a column change that touched every product.

$Trap 2: synchronous remote calls in the user's request

A user editing a product description on the backend product page expects the save button to respond instantly. With the naive override, the save's write() call doesn't return until every channel's push_to_channel() returns. Four channels × 200ms-2000ms each = 1-8 seconds best case, 30-60 seconds when any channel is slow or retrying.

Worse: any channel returning an exception (a rate-limit, a transient 502 from Walmart, an Amazon throttling response) bubbles up to the user as a red error toast in the Odoo UI. The user's actual edit succeeded (the local Odoo data was written), but the surfaced error says "save failed." The user retries. The write fires again. The cycle compounds.

$Trap 3: half-applied state when a channel errors mid-loop

The naive override iterates channels serially. If channel 1 (Amazon) succeeds, channel 2 (eBay) succeeds, channel 3 (Walmart) raises an exception, the user sees an error and the database commits the local write. But the post-write actions are partial: Amazon and eBay have the new state, Walmart has the old state. There's no automatic retry, and no signal in the database that Walmart is out of sync.

Two days later, an inventory audit finds Walmart's price is $89.99 while Amazon and eBay are $79.99. Nobody knows why. The root cause is the connector's eager-but-not-transactional write loop.

$Trap 4: no awareness of which fields changed

The naive override pushes the full listing to every channel on every write, even when the changed field is irrelevant to marketplace listings. Editing x_internal_notes, a custom text field for internal use, fires a full listing re-publish across all channels. The connector pays the cost of a full sync to update a field the marketplaces don't care about.

$The change-aware-write pattern

The correct override has four properties:

  1. Inspects which fields are in vals before deciding anything.
  2. Only marks affected listings for re-sync if a marketplace-relevant field actually changed.
  3. Enqueues async jobs instead of calling the network synchronously.
  4. Tracks per-channel sync status so partial failures are observable.

The shape:

class ProductTemplate(models.Model):
    _inherit = "product.template"

    # Declare which fields are marketplace-relevant.
    _marketplace_synced_fields = {
        "name", "description_sale", "list_price", "standard_price",
        "barcode", "default_code", "image_1920", "sale_ok", "active",
        "weight", "sale_delay", "categ_id",
    }

    def write(self, vals):
        result = super().write(vals)
        relevant_fields = set(vals.keys()) & self._marketplace_synced_fields
        if not relevant_fields:
            return result
        for product in self:
            for listing in product.marketplace_listing_ids:
                listing.with_delay(channel=f"marketplace-{listing.channel}").queue_sync(
                    changed_fields=list(relevant_fields),
                )
        return result

Five things this does that the naive version doesn't:

$Per-channel sync state

The other half of the fix is making partial-failure observable. Each marketplace.listing record gets per-channel sync-status fields:

last_sync_attempted_at = fields.Datetime()
last_sync_succeeded_at = fields.Datetime()
last_sync_error = fields.Text()
sync_state = fields.Selection([
    ("in_sync", "In sync"),
    ("queued", "Sync queued"),
    ("in_progress", "Sync in progress"),
    ("failed", "Sync failed"),
    ("stale", "Stale (last sync > 24h ago)"),
])

The queue_sync() job sets sync_state="in_progress", calls the channel API, sets sync_state="in_sync" on success or "failed" with the error message captured. A dashboard list of listings with sync_state="failed" surfaces the half-applied state that the naive version hides.

A cron job marks anything last_sync_succeeded_at < now - 24h as "stale", catching listings that fall behind silently because no edit triggered a re-queue.

$The cost of getting this right

The naive override is 6 lines. The correct one is ~40 lines + the queue_job dependency + the sync-state fields + the cron + the dashboard view. The 6-line version is "this works on my single-channel test" and "this is a production-grade multi-marketplace connector" separated by a 6× code size and a queueing infrastructure.

Most off-the-shelf connectors on apps.odoo.com sit somewhere between the two. Some have field-aware sync but no async queue. Some have async but no per-channel sync state. Almost none surface partial-failure state on the listing record. The asymmetry is what the audit looks for, and what makes the difference between "I edited a product description and now Walmart is wrong" and "the system told me Walmart sync failed and queued a retry."

By David H. Frost · Frost Labs LLC More writing · Home · Privacy