Product Updated
Se actualizó un producto (Activity o Box) elegible para indexarse. Lleva el documento completo. También es el evento del resync masivo.
Overview
search.product.updated.v1 se emite cuando cambia un producto que debe estar indexado. El
payload es el documento entero del producto y es idéntico al de
search.product.created.
Es el topic de mayor volumen del catálogo, porque además del flujo normal es el que usa el resync masivo.
Cuándo se dispara — son dos caminos
- 1
post_save (flujo normal)
search.signals.products_post_save: el producto ya existía y sigue cumpliendo los filtros de indexación. - 2
Resync masivo
SearchIndexer.index_async()recorre todos los productos de un país (o de todos) y emite un evento por cada uno. Es el pico de tráfico esperable del topic.
Existe un camino paralelo por HTTP (SearchIndexer.index() → sync_products_batch) que no
pasa por el bus. Solo ese camino hace reconciliación de documentos obsoletos.
Schema
Formato Avro, validado con fastavro.
{ "type": "record", "name": "ProductUpdated", "namespace": "search.product", "doc": "Emitido cuando se actualiza un producto indexable (Activity o Box) en bigbox-backend, y tambien por el resync masivo SearchIndexer.index_async. Payload = documento de producto serializado, identico al de search.product.created.v1. Consumido por el servicio search para indexar en Elasticsearch.", "fields": [ { "name": "id", "type": "int", "doc": "Id del producto." }, { "name": "type", "type": { "type": "enum", "name": "ProductType", "symbols": ["Activity", "Box"] }, "doc": "Discriminador. El consumer rutea a ActivityDocument o BoxDocument segun este campo; un valor desconocido lanza error y el mensaje se nackea." }, { "name": "country_id", "type": ["null", "int"], "default": null, "doc": "Id del pais del producto." }, { "name": "name", "type": "string", "doc": "Nombre del producto." }, { "name": "description", "type": ["null", "string"], "default": null }, { "name": "short_description", "type": ["null", "string"], "default": null }, { "name": "price", "type": ["null", "double"], "default": null, "doc": "Precio. En Box se toma del campo de precio del pais (Box.price_fields[country_id])." }, { "name": "participants", "type": { "type": "array", "items": "string" }, "doc": "Buckets de participantes: '1', '2', '2+'." }, { "name": "landing_slugs", "type": { "type": "array", "items": "string" }, "doc": "Slugs de landings donde aparece el producto (directas + por product tag)." }, { "name": "category", "type": [ "null", { "type": "record", "name": "Category", "fields": [ { "name": "id", "type": ["null", "int"], "default": null }, { "name": "name", "type": "string" }, { "name": "slug", "type": "string" } ] } ], "default": null }, { "name": "category_visible", "type": "boolean" }, { "name": "slug", "type": "string" }, { "name": "most_sold", "type": "boolean" }, { "name": "most_sold_order", "type": ["null", "int"], "default": null }, { "name": "rating", "type": ["null", "double"], "default": null, "doc": "score_satisfaction del producto." }, { "name": "individual_purchase_order", "type": ["null", "int"], "default": null }, { "name": "units_sold_last_30_days", "type": "int" }, { "name": "units_validated_last_30_days", "type": "int" }, { "name": "campaign_slugs", "type": { "type": "array", "items": "string" }, "doc": "Slugs de todas las campanas activas que incluyen al producto. No implica descuento." }, { "name": "product_tag_slugs", "type": { "type": "array", "items": "string" } }, { "name": "has_campaign_discount", "type": "boolean", "doc": "True si el producto esta en al menos una campana activa con descuento > 0." }, { "name": "has_deal_today", "type": "boolean", "doc": "True si hay un ActivityDiscount aprobado aplicable hoy. Siempre false para Box." },
{ "name": "individual_purchase_available", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "visible", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "box_names", "type": ["null", { "type": "array", "items": "string" }], "default": null, "doc": "Solo Activity: nombres de los boxes que la contienen. null cuando type = Box." }, { "name": "box_ids", "type": ["null", { "type": "array", "items": "int" }], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "partner_fantasy_name", "type": ["null", "string"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "has_online_reservation", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "reviews_qty", "type": ["null", "int"], "default": null, "doc": "Solo Activity (survey_qty). null cuando type = Box — ver observaciones del .md." }, { "name": "visible_catalog", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "country_delivery", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "is_online_activity", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "can_purchase_a_reservation", "type": ["null", "boolean"], "default": null, "doc": "Solo Activity. null cuando type = Box." }, { "name": "activity_type", "type": ["null", "string"], "default": null, "doc": "Solo Activity: Activity | PartnerActivity | ComboActivity | TravelActivity. null cuando type = Box." }, { "name": "province", "type": ["null", { "type": "array", "items": { "type": "record", "name": "Province", "fields": [ { "name": "id", "type": "int" }, { "name": "name", "type": "string" }, { "name": "slug", "type": "string" } ] } }], "default": null, "doc": "Provincias del producto. Activity: derivadas de sus locations (o todas las del pais si es online / country_delivery). Box: derivadas de sus activities activas." }, { "name": "cities", "type": ["null", { "type": "array", "items": { "type": "record", "name": "City", "fields": [ { "name": "id", "type": "int" }, { "name": "name", "type": "string" }, { "name": "province_id", "type": ["null", "int"], "default": null } ] } }], "default": null }, { "name": "subfilters", "type": ["null", { "type": "array", "items": { "type": "record", "name": "SubFilter", "fields": [ { "name": "id", "type": "int" }, { "name": "name", "type": "string" }, { "name": "parent_id", "type": ["null", "int"], "default": null }, { "name": "parent_name", "type": ["null", "string"], "default": null }, { "name": "parent_order", "type": ["null", "int"], "default": null } ] } }], "default": null }, { "name": "availability", "type": ["null", { "type": "array", "items": { "type": "record", "name": "AvailabilitySlot", "doc": "Slots con disponibilidad desde hoy hasta el maximo rango de ActivityAvailabilityService.", "fields": [ { "name": "location_id", "type": "int" }, { "name": "province_id", "type": ["null", "int"], "default": null }, { "name": "city_id", "type": ["null", "int"], "default": null }, { "name": "date", "type": ["null", "string"], "default": null }, { "name": "time", "type": ["null", "string"], "default": null }, { "name": "bookable_until", "type": ["null", "string"], "default": null } ] } }], "default": null, "doc": "Solo Activity. null cuando type = Box." },
{ "name": "has_an_activity_with_visible_catalog", "type": ["null", "boolean"], "default": null, "doc": "Solo Box. null cuando type = Activity." }, { "name": "activities_count", "type": ["null", "int"], "default": null, "doc": "Solo Box. null cuando type = Activity." }, { "name": "activities_visible_catalog_count", "type": ["null", "int"], "default": null, "doc": "Solo Box. null cuando type = Activity." },
{ "name": "occurred_at", "type": "string", "doc": "ISO-8601 naive en hora local del proceso. Lo inyecta events.event.Event.__init__." } ]}Un payload, dos formas
Igual que en created: el serializador declara los campos de los dos tipos de producto y
rellena con null los que no aplican. Ver el detalle de campos en
search.product.created.
Consumidores
search hace upsert del documento completo en el índice products.
El upsert reescribe el documento entero, incluido el array availability. Como no hay
ordenamiento garantizado entre topics, un product.updated puede sobreescribir la
disponibilidad que acaba de dejar un
reservations.availability.updated.
Deuda conocida
- Mismos puntos que
created:reviews_qtynull en Box,created_at/updated_atque nunca viajan, y costo alto de serialización por producto. - El resync masivo emite un evento por producto y por país a través de Cloud Tasks. Vale medirlo antes de correrlo en horario pico.