Dagster Orchestration
This commit is contained in:
@@ -202,7 +202,13 @@ def get_catalog_batch(batch_id: str) -> BatchOut:
|
||||
|
||||
@router.post("/batches/{batch_id}/resume", dependencies=[Depends(require_admin)])
|
||||
def resume_catalog_batch(batch_id: str) -> BatchOut:
|
||||
"""Re-queue a batch a restart cut short, or one that was queued behind a full queue."""
|
||||
"""Re-queue a batch a restart cut short, or one that was queued behind a full queue.
|
||||
|
||||
Also the way out of a batch staged for Dagster that no orchestrator ever
|
||||
came for - the "run it here instead" button. Because this hands the batch to
|
||||
THIS container's worker, it also takes ownership: the runner is flipped to
|
||||
`inprocess` so Dagster will not claim a batch that is already running here.
|
||||
"""
|
||||
manifest = batch_ingest.read_manifest(batch_id)
|
||||
if not manifest:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
@@ -217,6 +223,7 @@ def resume_catalog_batch(batch_id: str) -> BatchOut:
|
||||
batch_job_store.clear_cancel(batch_id)
|
||||
manifest.status = batch_ingest.QUEUED
|
||||
manifest.detail = None
|
||||
manifest.runner = batch_ingest.RUNNER_INPROCESS
|
||||
batch_ingest.write_manifest(manifest)
|
||||
batch_job_store.put(manifest)
|
||||
|
||||
@@ -314,6 +321,10 @@ class InboxStartRequest(InboxSelection):
|
||||
# belongs to the person who can see what the machine is already doing.
|
||||
use_llm: bool = False
|
||||
fetch_images: bool = False
|
||||
# Who runs it. "inprocess" is this container's worker thread and is the
|
||||
# default, so an existing client that never sends the field is unaffected.
|
||||
# "dagster" stages the batch and leaves it for the orchestrator to claim.
|
||||
runner: str = batch_ingest.RUNNER_INPROCESS
|
||||
|
||||
|
||||
class InboxDismissOut(BaseModel):
|
||||
@@ -405,6 +416,14 @@ def start_batch_from_inbox(request: InboxStartRequest) -> BatchOut:
|
||||
The originals are removed afterwards, so the same sheet cannot be started
|
||||
twice from a stale checkbox in another tab.
|
||||
"""
|
||||
if request.runner not in batch_ingest.RUNNERS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Unknown runner {!r}. Expected one of: {}.".format(
|
||||
request.runner, ", ".join(sorted(batch_ingest.RUNNERS))
|
||||
),
|
||||
)
|
||||
|
||||
grouped = _parse_file_ids(request.file_ids)
|
||||
if not grouped:
|
||||
raise HTTPException(status_code=400, detail="No files were selected.")
|
||||
@@ -447,23 +466,35 @@ def start_batch_from_inbox(request: InboxStartRequest) -> BatchOut:
|
||||
unique_senders = sorted(set(senders))
|
||||
submitted_by = ", ".join(unique_senders)[:120] if unique_senders else None
|
||||
|
||||
manifest, started = batch_common.stage_and_queue(
|
||||
picked,
|
||||
[],
|
||||
use_llm=request.use_llm,
|
||||
fetch_images=request.fetch_images,
|
||||
submitted_by=submitted_by,
|
||||
)
|
||||
if not started:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=(
|
||||
"Too many batches are already queued. These files have been "
|
||||
"taken out of the inbox and saved as batch "
|
||||
f"{manifest.batch_id} - press Resume on it once the current "
|
||||
"batch finishes."
|
||||
),
|
||||
if request.runner == batch_ingest.RUNNER_DAGSTER:
|
||||
# Staged and left alone: Dagster claims it on its next sensor tick, or
|
||||
# from the Launchpad. Nothing here waits on that, and the batch is
|
||||
# durable either way.
|
||||
manifest = batch_common.stage_for_orchestrator(
|
||||
picked,
|
||||
[],
|
||||
use_llm=request.use_llm,
|
||||
fetch_images=request.fetch_images,
|
||||
submitted_by=submitted_by,
|
||||
)
|
||||
else:
|
||||
manifest, started = batch_common.stage_and_queue(
|
||||
picked,
|
||||
[],
|
||||
use_llm=request.use_llm,
|
||||
fetch_images=request.fetch_images,
|
||||
submitted_by=submitted_by,
|
||||
)
|
||||
if not started:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=(
|
||||
"Too many batches are already queued. These files have been "
|
||||
"taken out of the inbox and saved as batch "
|
||||
f"{manifest.batch_id} - press Resume on it once the current "
|
||||
"batch finishes."
|
||||
),
|
||||
)
|
||||
|
||||
# Only now, once the bytes are safely staged under a new id. Retiring them
|
||||
# first would lose the files outright if staging then failed.
|
||||
|
||||
@@ -173,7 +173,13 @@ _KEYWORD_RULES: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||
("description", ("description", "desc", "detail")),
|
||||
("category", ("category", "segment")),
|
||||
("brand", ("brand", "manufacturer", "company")),
|
||||
("size_variants", ("size", "pack", "weight", "volume", "net qty", "quantity")),
|
||||
# "net qty" / "net quantity" is the Indian labelling term for a pack size.
|
||||
# A bare "Quantity" column is not: in a store sheet it is how many units the
|
||||
# shop has or is ordering, and mapping it here made a case-pack count of 72
|
||||
# into the pack size, which then got appended to the product name. Worse,
|
||||
# mapping is first-wins by column position, so a leading "Quantity" column
|
||||
# also shut out the sheet's real "Pack Size" column.
|
||||
("size_variants", ("size", "pack", "weight", "volume", "net qty", "net quantity")),
|
||||
("providers", ("provider", "platform", "marketplace", "available at")),
|
||||
("highlights", ("highlight", "feature", "benefit")),
|
||||
("nutrients", ("nutrient", "nutrition")),
|
||||
@@ -186,12 +192,38 @@ _KEYWORD_RULES: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||
_BLANK_VALUES = frozenset({"", "nan", "none", "null", "na", "n/a", "-", "--", "#n/a"})
|
||||
|
||||
|
||||
# Fields whose value is a NAME, and which therefore must not be fed from a
|
||||
# sheet's id/code column for the same concept. The keyword rules match on
|
||||
# substring, so "categoryid" satisfies the "category" rule and a column of
|
||||
# 1/2/3 ends up stored as the product's category. Only these two fields need
|
||||
# the guard: "hsn code" and "barcode" ARE identifier fields and must keep
|
||||
# matching their rules.
|
||||
_NAME_ONLY_FIELDS = frozenset({"category", "brand"})
|
||||
_IDENTIFIER_SUFFIXES = ("id", "ids", "code", "codes", "no", "num", "number")
|
||||
|
||||
|
||||
def _is_identifier_header(normalized: str) -> bool:
|
||||
"""True when a header names an id/code column rather than a name column,
|
||||
covering both "categoryid" and "category id" (the normalizer strips the
|
||||
underscore in "category_id" to a space, and nothing at all from
|
||||
"categoryid")."""
|
||||
return any(
|
||||
normalized.endswith(suffix) and normalized[: -len(suffix)].strip()
|
||||
for suffix in _IDENTIFIER_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def _canonical_field(normalized: str) -> Optional[str]:
|
||||
exact = _EXACT_HEADERS.get(normalized)
|
||||
if exact:
|
||||
return exact
|
||||
for canonical, keywords in _KEYWORD_RULES:
|
||||
if any(keyword in normalized for keyword in keywords):
|
||||
if canonical in _NAME_ONLY_FIELDS and _is_identifier_header(normalized):
|
||||
# An id column for a name field: claim nothing, so the real
|
||||
# name column (if the sheet has one) is still free to match and
|
||||
# the id is reported back under `unrecognised` in the preview.
|
||||
return None
|
||||
return canonical
|
||||
return None
|
||||
|
||||
@@ -453,14 +485,24 @@ def _build_product_dict(req: AddProductRequest, brand_parent: str,
|
||||
if s3_urls:
|
||||
final_image_urls = list(s3_urls)
|
||||
|
||||
# 2. Inherit from brand sample
|
||||
if not final_image_urls and sample_existing.get("image_urls"):
|
||||
final_image_urls = list(sample_existing.get("image_urls"))
|
||||
|
||||
# 3. Canonical S3 fallback URL
|
||||
# A product's images are NOT inheritable from its brand.
|
||||
#
|
||||
# This used to fall back to `sample_existing["image_urls"]` - the
|
||||
# `_brand_sample()` row, i.e. one arbitrary product of the brand,
|
||||
# resolved once and reused for every row in the upload. It copied that
|
||||
# product's photographs verbatim onto every image-less sibling, which is
|
||||
# why Marie Gold and Milk Bikis both shipped carrying four
|
||||
# `britannia_..._good_day_cashew_cookies_200g/` URLs while their own
|
||||
# image_ids were perfectly correct. Another product's photo is never a
|
||||
# defensible default for this one, so there is no fallback here.
|
||||
#
|
||||
# Nor is a URL invented. The old canonical fallback guessed
|
||||
# `https://nearledaily.s3.ap-south-1.amazonaws.com/...` while the
|
||||
# configured bucket is DigitalOcean Spaces (see S3_ENDPOINT), so it
|
||||
# produced a guaranteed 404 that merely looked like an image. Leaving
|
||||
# the list empty lets ProductCard render its real "no image" state.
|
||||
if not final_image_urls:
|
||||
canonical_s3 = f"https://nearledaily.s3.ap-south-1.amazonaws.com/daily/brands/{brand_slug}/{image_id}/image_000.jpg"
|
||||
final_image_urls = [canonical_s3]
|
||||
logger.info("No image found for '%s' (%s)", product_name, image_id)
|
||||
|
||||
primary_image_url = final_image_urls[0] if final_image_urls else None
|
||||
search_text = f"{brand_parent} {product_name} {category} {description} {price_range}"
|
||||
|
||||
Reference in New Issue
Block a user