Skip to main content

6 posts tagged with "Bug修复"

View all tags

Clicking One Row Highlights Many in Ant Design Table? Your rowKey Isn't Unique

· 5 min read

Clicking a table row on a reporting page to inspect details, the clicked row — plus several other rows — highlighted at the same time, while the console flooded with React duplicate key warnings.

Encountered this while building AI Ops — LLM-powered analysis that surfaces market trends, user behavior, and sales insights to drive precise operations strategy. The ad weekly-report page contains several detail tables (campaign × keyword, campaign × area), each supporting click-to-highlight so users can drill into a row's delivery details. After launch, clicking any row "lit up" every row under the same campaign.

TL;DR​

Ant Design's Table uses the return value of rowKey as each row's React key. When that field isn't the data's real business key — guessed by naming, or missing a dimension that participates in uniqueness — multiple rows generate identical keys: every key-matched row interaction (selection, highlight, expansion) hits multiple rows at once, and React throws duplicate key warnings. The fix: query information_schema for the table's actual columns, pick a truly unique column or composite columns for rowKey, and verify with GROUP BY HAVING.

The Symptom​

A minimal reproduction (Ant Design 5 + React 18):

import { Table } from 'antd';
import { useState } from 'react';

// Data granularity: keyword × product — one keyword splits into multiple rows per promoted product
const data = [
{ keyword_id: 88, keyword: 'summer dress', offer_id: 101, clicks: 12 },
{ keyword_id: 88, keyword: 'summer dress', offer_id: 102, clicks: 7 },
{ keyword_id: 90, keyword: 'maxi skirt', offer_id: 103, clicks: 5 },
];

export default function WeeklyKeywords() {
const [selected, setSelected] = useState<string[]>([]);
return (
<Table
rowKey={(r) => String(r.keyword_id)} // Pitfall: keyword_id is not unique at this granularity
columns={[
{ title: 'Keyword', dataIndex: 'keyword' },
{ title: 'Product', dataIndex: 'offer_id' },
{ title: 'Clicks', dataIndex: 'clicks' },
]}
dataSource={data}
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
onRow={(r) => ({ onClick: () => setSelected([String(r.keyword_id)]) })}
/>
);
}

Two symptoms: clicking the first row highlights both rows with keyword_id 88, and the console repeatedly prints:

Warning: Encountered two children with the same key, `88`.
Keys should be unique so that components maintain their identity across updates.

Root Cause​

An antd Table row's identity is exactly the return value of rowKey. It becomes the React key of that row's element. When keys duplicate, React's diff treats multiple rows as the same element: rendering can go wrong and controlled state bleeds between rows.

Every key-matched row interaction gets amplified. rowSelection's selectedRowKeys, onRow clicks, and expandedRowKeys all match by key — with duplicate keys, one match hits multiple rows. That's the direct cause of "click one, highlight many."

The wrong key column usually comes from the data side. The actual root cause here: key columns were guessed from naming — we assumed the area table had region_id, but its real business key column was area_name; we assumed the keyword table's granularity was keyword, but it was actually keyword × product (offer_id also participates in the unique key). Miss one dimension and every row in a group shares the same rowKey.

The Fix​

Step 1: Query the table's actual columns — don't guess from names​

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'ad_weekly_keywords'
ORDER BY ordinal_position;

Confirm which columns actually form the business key, and whether the column you assumed even exists.

Step 2: Verify the key (or key combination) is unique​

SELECT keyword, offer_id, COUNT(*)
FROM ad_weekly_keywords
GROUP BY keyword, offer_id
HAVING COUNT(*) > 1;
-- 0 rows = unique; also confirm key columns contain no NULLs

Step 3: Configure rowKey with a composite key​

<Table
rowKey={(r) => `${r.keyword}::${r.offer_id}`}
// Or even safer: JSON.stringify([r.keyword, r.offer_id])
dataSource={data}
...
/>

When concatenating composite keys, use a separator that cannot appear in the field values (or just JSON.stringify the array) to avoid collisions between a + b and ab.

After the change, clicking a row highlights only that row, and duplicate key warnings drop to zero. This is the same family of problems as React list key duplicates causing DOM errors — only with unique keys can diffing and row interactions be correct.

Heads up

Before choosing key columns, query information_schema for the table's actual columns — don't guess from field names: business key columns can differ entirely from intuition (the table has only area_name, no region_id; keyword granularity is actually keyword × product).

The duplicate key warning is not "just a warning": it means React reconciliation is broken — row state bleeding, wrong highlights, and updates not taking effect can all follow. It must go to zero.

After changing rowKey, re-verify uniqueness with GROUP BY ... HAVING COUNT(*) > 1, and check key columns for NULLs — NULL keys create duplicates too.

FAQ​

How should I set rowKey on an Ant Design Table?​

Use the field — or combination of fields — that uniquely identifies a row: a single unique field works directly; if no single field is unique, build a composite key from multiple columns (with a collision-proof separator or JSON.stringify). Never use a non-unique business field, and don't take shortcuts with array index — after sorting, filtering, or pagination, state will bleed between rows.

How do I fix the React duplicate key warning?​

Duplicate keys make React treat multiple nodes as the same element, corrupting rendering and state. Locate the list rendering site producing the duplicate keys, switch to a truly unique key, then verify uniqueness at the data source with GROUP BY HAVING — silencing the warning without checking the data means the problem will resurface in another form.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

DeepSeek/Qwen Structured Calls Succeed but Return Empty? Disable Thinking Before It Burns Your Token Budget

· 6 min read

While running batch LLM semantic validation over production data, all 2,449 calls "succeeded" — yet every single result fell back to the default value, and the logs contained almost no failures.

Encountered this while building AI Ops — LLM-powered analysis that surfaces market trends, user behavior, and sales insights to drive precise operations strategy. The product title optimization pipeline asks an LLM to semantically validate "keyword × product" pairs: one call per keyword batch, returning a tiny JSON judgment. This should be the simplest kind of LLM call — yet on the first production run, all 2,449 pairs silently fell back, and the validation layer produced zero effective LLM judgments.

TL;DR​

Thinking-capable models like DeepSeek and Qwen share a single max_tokens budget between reasoning text and final content. If structured small-output calls don't explicitly disable thinking, the reasoning chain eats the entire budget on its own: finish_reason becomes length, content comes back empty — and since the parse-retry loop only logs exceptions, the silent retries exhaust and degrade the whole batch without a single error. Two things to do: disable thinking explicitly for structured calls, and log finish_reason on parse failure instead of only catching exceptions.

The Symptom​

This minimal reproduction shows the whole process (requires pip install openai and a thinking-capable model):

import os
from openai import OpenAI

client = OpenAI(
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com",
)

resp = client.chat.completions.create(
model="deepseek-reasoner",
messages=[
{
"role": "user",
"content": (
'判断下面的关键词是否适合写入商品标题,'
'只返回 JSON:{"suitable": true} 或 {"suitable": false}。\n'
"关键词:summer women dress\n"
"商品:floral midi dress for women"
),
}
],
max_tokens=2000, # reasoning and content share this budget
)

print("finish_reason:", resp.choices[0].finish_reason)
print("content:", repr(resp.choices[0].message.content))

Typical output with thinking enabled:

finish_reason: length
content: ''

Everything looks fine at the HTTP layer: no timeout, no 5xx, no SDK exception. If the outer layer is a "retry on parse failure, fall back after retries exhaust" loop, the logs end up with a single "retries exhausted" warning — which reads exactly like an intermittent network problem.

Root Cause​

Thinking models don't budget reasoning separately. In DeepSeek, Qwen and similar models, reasoning content and final content share the same max_tokens ceiling — there is no independent "reasoning budget" field.

Structured small-output calls tend to use small budgets. A boolean judgment is expected to produce a few dozen tokens, so max_tokens=2000 looks generous — but the reasoning chain's length is completely uncontrolled. Once it consumes all 2,000 tokens, the model never gets a chance to emit the actual answer: finish_reason returns length, content is an empty string, yet the API returns 200 normally.

There's also an amplifier on the engineering side. An empty string is not valid JSON, but many retry loops only catch network and API exceptions, treating parse failure as "no result this round" and retrying silently. This kind of exception-swallowing silent failure is notoriously hard to diagnose inside retry loops: all N retries fail for the same root cause, yet the log shows only the final fallback warning — easy to misread as network flakiness.

The Fix​

Step 1: Explicitly disable thinking for structured-output calls​

Boolean judgments, JSON extraction, and classification calls don't need multi-step reasoning. Disable thinking per provider:

def thinking_disabled_extra_body(provider: str) -> dict:
"""Structured small-output calls: disable thinking explicitly per provider."""
if provider == "deepseek":
return {"thinking": {"type": "disabled"}}
if provider == "qwen":
return {"enable_thinking": False}
return {}


resp = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=2000,
extra_body=thinking_disabled_extra_body("deepseek"),
)

With thinking off, the entire 2,000-token budget goes to the JSON judgment itself. After the fix, rerunning the batch returned valid judgments for all 2,449 keyword×product pairs — before the fix, the whole batch silently degraded, leaving just 3 "retries exhausted" warnings in the logs.

Step 2: Don't let parse failures go silent​

Even with thinking disabled, turn "parse failure" into an evidence-bearing log line, so next time an empty output occurs — for any reason — a single log line locates it:

import json
import logging

logger = logging.getLogger(__name__)


def parse_judgment(resp) -> dict | None:
content = resp.choices[0].message.content
try:
return json.loads(content)
except (TypeError, json.JSONDecodeError):
# Key: log finish_reason and raw content, not just exceptions
logger.warning(
"LLM output parse failed: finish_reason=%s content=%r",
resp.choices[0].finish_reason,
content,
)
return None

When debugging LLM degradation, check finish_reason first: length means the output budget was exhausted (most likely by reasoning), while stop means normal completion. This is far more effective than scrolling exception logs.

If your returned JSON also passes through schema validation and you use Zod in a TypeScript project, watch out for this related pitfall: Zod schema validation silently dropping LLM output.

Heads up

The parameter to disable thinking is not standardized across providers: DeepSeek uses {"thinking": {"type": "disabled"}}, Qwen uses {"enable_thinking": False}, and OpenAI's o-series uses reasoning_effort-style parameters. Check each provider's docs before integrating — don't assume the parameter is universal.

If a provider's thinking cannot be disabled, you must raise max_tokens based on measured reasoning length — otherwise the same silent degradation will happen again.

FAQ​

How do I disable thinking output in DeepSeek?​

On the OpenAI-compatible API, pass {"thinking": {"type": "disabled"}} via extra_body; Qwen uses {"enable_thinking": False}. Structured small-output calls like boolean judgments and JSON extraction don't need a reasoning chain — turn it off by default so the entire budget goes to the actual answer.

Why does my DeepSeek API call succeed but return empty content?​

Thinking models share max_tokens between reasoning and content. When reasoning exhausts the budget, finish_reason returns length and content is empty — with no exception thrown. Disable thinking or raise max_tokens based on measured reasoning length, and check finish_reason before digging through exception logs.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Metrics Inflated 20x After SQL Aggregation? Never SUM Ratio Columns

· 6 min read

While aggregating daily ad data into weekly reports, the ratio metrics — PPC, CPM, ROI — jumped by tens of times: PPC went from 4.70 in the daily detail to 111.39 in the weekly report.

Encountered this while building AI Ops — LLM-powered analysis that surfaces market trends, user behavior, and sales insights to drive precise operations strategy. The ad data API returns daily detail rows, which must be aggregated to week granularity before feeding reports. The first check after launch: PPC 111.39 (daily actual 4.70), CPM 1585.9 (daily actual 68.6), ROI 169.98 (daily actual 1.82) — all 13 ratio/average columns distorted.

TL;DR​

Applying SUM directly to ratio/average columns like CTR, PPC, ROI in an aggregation query yields "the sum of daily ratios," not a mean — values inflate proportionally to the number of days aggregated. Two rules: aggregate only additive columns (impressions, clicks, cost — the totals), skipping every ratio column; recompute ratios from the totals after aggregating (cost ÷ clicks, clicks ÷ impressions). If you only have ratios without the underlying totals, take a weighted average using the denominator as weight — never a plain average.

The Symptom​

The aggregation code SUMmed "every numeric column," silently sweeping ratio columns along:

-- Wrong: SUM every column
SELECT
campaign_id,
date_trunc('week', day) AS week,
SUM(clicks) AS clicks,
SUM(impressions) AS impressions,
SUM(ppc) AS ppc, -- sum of 7 daily PPC values!
SUM(ctr) AS ctr, -- sum of 7 daily CTR values!
SUM(roi) AS roi -- sum of 7 daily ROI values!
FROM daily_ad_report
GROUP BY campaign_id, date_trunc('week', day);

Measured comparison (one campaign, one week):

MetricDaily actualSUM weeklyInflation
ppc4.70111.39~24×
cpm68.61585.9~23×
roi1.82169.98~93×

No errors anywhere: data ingested normally, reports rendered normally — only by comparing the weekly report against daily details side by side could you see values off by one to two orders of magnitude.

Root Cause​

Ratios and averages are non-additive derived quantities. Each day's ppc = cost / clicks has a different denominator; adding 7 daily PPC values mathematically yields "a sum of 7 relative values," which has no business meaning. CTR and ROI are the same.

"Sum all numeric columns" is a silent trap. Aggregation code usually loops over columns uniformly, and ratio columns slip in without error or warning — results just quietly distort. The more columns you have, the harder it is to spot by eye.

An ROI inflated 93× is actually more deceptive. It reads as "outstanding ad performance." If downstream consumers read the weekly report directly to make budget decisions, the wrong number propagates all the way into operational actions. In this incident, several read-only consumers queried the weekly table directly — until the table was corrected, every consumer was reading wrong data.

The Fix​

Step 1: Keep only additive columns in the aggregation​

CREATE VIEW weekly_ad_totals AS
SELECT
campaign_id,
date_trunc('week', day) AS week,
SUM(impressions) AS impressions,
SUM(clicks) AS clicks,
SUM(cost) AS cost,
SUM(gmv) AS gmv
FROM daily_ad_report
GROUP BY campaign_id, date_trunc('week', day);

Additive columns are counts/totals (impressions, clicks, cost, orders) — summing them across time intervals still means something.

Step 2: Recompute all ratios from totals after aggregating​

SELECT
campaign_id,
week,
impressions,
clicks,
cost,
CASE WHEN clicks > 0
THEN cost / NULLIF(clicks, 0)::numeric
ELSE 0 END AS ppc,
CASE WHEN impressions > 0
THEN clicks::numeric / NULLIF(impressions, 0)
ELSE 0 END AS ctr,
CASE WHEN cost > 0
THEN (gmv - cost)::numeric / NULLIF(cost, 0)
ELSE 0 END AS roi
FROM weekly_ad_totals;

Two details: PostgreSQL integer division truncates, so cast with ::numeric before dividing; return 0 when the denominator is 0, keeping the same convention as the daily layer.

Step 3: When you only have ratios, use a weighted average​

-- Aggregate daily CTR weighted by impressions (expanded form: SUM(ctr × impressions) / SUM(impressions))
SELECT
date_trunc('week', day) AS week,
SUM(clicks)::numeric / NULLIF(SUM(impressions), 0) AS ctr_weighted
FROM daily_ad_report
GROUP BY date_trunc('week', day);

A weighted average is essentially "reconstruct numerator and denominator, then divide" — as long as you can still access the weight column, always prefer it over a plain average.

After the fix, all 13 ratio columns in the weekly report matched the measured daily values. Historical dirty data was backfilled with the same formulas, and read-only downstream consumers became correct automatically — without changing a single line of code.

Heads up

Generic aggregation code that "sums every numeric column" is the source of this class of incident: maintain an allowlist of additive columns, explicitly excluding ratio/average columns. When adding a new metric column, first answer: "does summing this across days still mean something?"

When multiple granularities (weekly, monthly) derive from the same daily table, converge "recompute ratios from totals" into one function or view instead of copying the formula into each report SQL — metric-definition drift usually starts with copy-paste.

After fixing the aggregation logic, remember to backfill historical data: aggregation errors usually persist for many cycles, and fixing code without backfilling leaves old wrong values in the reports.

FAQ​

Can you sum percentages?​

No. Percentages and ratios are relative values with different denominators per row — SUMming them yields a sum of N relative values, inflated by the number of periods aggregated, with no business meaning. The correct approach is to skip ratio columns during aggregation and recompute from totals afterward (clicks ÷ impressions, cost ÷ clicks); when you only have ratios without totals, take a weighted average using the denominator as weight.

Can you add percentages together to get an average?​

Only when the denominators are identical. Averaging percentages with different denominators is an unweighted average that skews toward small-denominator items (extreme ratios from low-traffic days get amplified). The correct approach is to sum numerators and denominators separately, then divide — mathematically equivalent to a weighted average by denominator.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Python json.dumps with default=str turns a set into a string? The hidden substring-match trap

· 7 min read

When you persist a dict containing a Python set with json.dumps(data, default=str) and later read it back to test membership with in, the result is silently wrong — no exception, but the in checks are completely off.

Encountered this while building AI Ops — AI-powered analytics that surfaces market trends, user behavior, and sales data to drive precise operational strategy. In a decision-replay feature for an analysis template, I needed to serialize a "missing months" set into a snapshot and read it back to decide whether a given month was missing. After replay, months that should have been flagged "missing" were silently judged "not missing" — with zero exceptions anywhere in the chain.

TL;DR​

default=str is not a universal escape hatch. It hands set to str(), storing a literal string like "{1, 2}" in the JSON instead of an array; the type is irreversible on reload, and an in check against it degrades to substring matching, silently returning wrong results. When set is involved, the correct approach is to convert to list before serializing and rebuild with set() on read.

The symptom​

This code fully reproduces the silent failure:

import json

# A dict containing a set — say, "months that still need backfill"
data = {"missing_months": {"3", "5", "12"}}

# Serialize with default=str (the common "just don't crash" shortcut)
serialized = json.dumps(data, default=str)
print(serialized)
# {"missing_months": "{'3', '5', '12'}"} ← a string, not an array!

# Read it back
back = json.loads(serialized)
value = back["missing_months"]
print(type(value)) # <class 'str'> ← no longer a set

# Silent bug: you meant to test membership in the "missing" set
print("1" in value) # True ← 1 is NOT in {3,5,12}, but "1" is a substring of "12"!
print("3" in value) # True ← correct by coincidence
print("9" in value) # False

"1" in value returns True, yet the original set {"3", "5", "12"} does not contain "1". No exception, no warning — the result is just quietly wrong. This kind of bug is especially dangerous in branches that act on the check (e.g. "is this month missing data? if so, backfill it").

Root cause​

Three layers:

Layer 1: set is not JSON serializable to begin with. JSON has only array (list) and object — no set type. A direct json.dumps({"x": {1, 2}}) raises TypeError: Object of type set is not JSON serializable.

Layer 2: default=str turns the error into silent corruption. The default parameter of json.dumps is called for objects that can't be serialized, and is expected to return a serializable value. When default=str, the object goes to str() — so a set becomes its Python literal form {'3', '5', '12'}, stored as a string in the JSON:

>>> json.dumps({"m": {"3", "5", "12"}}, default=str)
'{"m": "{\'3\', \'5\', \'12\'}"}'

The error is gone — at the cost of the type silently changing from set to str, with no signal that it happened.

Layer 3: in means different things for str vs set. This is the core of the silent bug. For set/list, x in s is a membership test; for str, x in s degrades to substring matching. The reloaded value is the string "{'3', '5', '12'}", so "1" in "{'3', '5', '12'}" tests whether the substring "1" appears — and since "12" contains "1", it returns True.

This is the same family of trap as Airflow PostgresHook silently dropping multi-statement SQL results: the most dangerous bugs don't throw — they silently return the wrong answer, leaving you no signal to investigate.

The fix​

Core principle: store only standard JSON types; rebuild set semantics on the read side.

The most direct and controllable approach — when you know where the set is, convert it to list in place:

import json

# Before serializing: set → list (a standard JSON array)
data = {"missing_months": list({"3", "5", "12"})}
serialized = json.dumps(data)
print(serialized)
# {"missing_months": ["3", "5", "12"]} ← a proper JSON array

# Rebuild the set after reading back
back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False ✓
print("3" in months) # True ✓

The serialized result is a clean JSON array — portable, readable, and restorable.

Option 2: a custom default function (when data is complex)​

If the data structure is deep and you're not sure where a set might sneak in, use a default function dedicated to collection types — preserving semantics while still falling back for other non-standard types:

import json

def safe_default(obj):
# Collection types → list, kept as a standard JSON array
if isinstance(obj, (set, frozenset)):
return sorted(obj) # sort for stable, predictable output
# Only fall back to str for types that truly can't be represented
return str(obj)

data = {"missing_months": {"3", "5", "12"}, "created_at": some_datetime}
serialized = json.dumps(data, default=safe_default)
# {"missing_months": ["3", "5", "12"], "created_at": "..."}

back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False ✓

Compared to a blind default=str, this function handles "types you need to preserve" (collections) explicitly and only falls back to str for genuinely unrepresentable types — minimizing silent risk.

Caveats

  • default=str is "silent", not "safe": it removes the error but flattens set/tuple/datetime/custom objects into strings irreversibly. Any operation that depends on the original type after reload (in membership, arithmetic, comparison) can misbehave.
  • tuple has the same problem: str((1, 2)) is "(1, 2)", and in against it also degrades to substring matching. Handle collection-like containers the same way: serialize as list.
  • Cross-process / cross-language portability is the litmus test: if this JSON will be read by Node.js, Go, etc., the "{1, 2}" produced by default=str is just a plain string there — not even a valid Python literal — and is nearly impossible to restore. Stick to standard JSON types for portability.
  • Convert at the source when possible: rather than patching with default after the fact, store collection semantics as list when you build the data structure, keeping set out of the serialization pipeline entirely.

FAQ​

How do I convert a Python set to JSON?​

A set has no native JSON type, so json.dumps raises TypeError. Convert it with list(set) before serializing to store a standard JSON array, then rebuild with set() when reading it back. This avoids the error and fully restores the set semantics, across languages too.

How do I fix "Object of type set is not JSON serializable" in json.dumps?​

The root cause is that sets aren't JSON serializable. The safe fix is to convert the set to a list before dumping, or pass a default function that returns list(obj) for isinstance(obj, (set, frozenset)). Avoid default=str — it doesn't crash, but it stores the set as a string, so the type can't be restored on read.

Why does in return wrong results after serializing a set with default=str?​

default=str passes the set to str(), storing the literal string '{1, 2}' in JSON. On reload the value is a str, not a set, so x in s degrades from membership testing to substring matching — e.g. "1" in "{'3','5','12'}" returns True because "12" contains the character "1", even though the original set doesn't contain "1". The fix is to serialize as list and rebuild with set() on read.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Docker Compose service won't come back after restart? Check the restart policy

· 5 min read

Debugging a Milvus-dependent service that failed to start in a RAG knowledge base project — full writeup below.

TL;DR​

After a host reboot (or a container crash), a group of services didn't come back: the app port had no listener and docker ps -a showed everything Exited. The root cause: docker-compose.yml had no restart policy (default no), so once a container died it stayed dead. Fix: set restart: always on every production service so the infrastructure self-heals after a crash or reboot.

Python task marked failed but no error? try/except swallowed the exception

· 5 min read

Debugging a silent failure where a document sync task marked everything failed in a RAG knowledge base project — full writeup below.

TL;DR​

A shared method was refactored with a new parameter signature, but one caller was missed. The caller passed arguments under the old contract and threw TypeError — except the call sat inside a try/except that quietly funneled the exception into a failed counter. No crash, no ERROR in the logs, just a number ticking up. These "silent failures" are the hardest bugs to track down. Two fixes: grep all callers after a signature refactor; and make except blocks log or re-raise, never swallow silently.