| Metric | chat | tencent/hy3 |
|---|---|---|
| Wins | 7 | 3 |
| Ties | 0 | 0 |
| Avg score | 7.670 | 7.480 |
| Avg time | 24.7s | 1m14s |
| Avg speed | 237 tok/s | 21 tok/s |
| Tasks | 10 | 10 |
| Restarts | 0 | 7 |
Winner: chat (7/10 tasks)
Score edge: chat +0.190
| chat | tencent/hy3 | |
|---|---|---|
| Score | 4.0 | 6.0 |
| Time | 1m33s | 1m8s |
| Tokens | 21306 | 1490 |
| Speed | 229 tok/s | 22 tok/s |
| Restarts | 0 | 2 |
Winner: Answer B (tencent/hy3)
Judge labels: chat = Answer A; tencent/hy3 = Answer B
Judge JSON: winner=answer_b; answer_a_score=4.0; answer_b_score=6.0
Judge reasoning:
Answer B is closer to the standard correct change-point algorithm and supports O(log n) lookup and O(k log n) assignment without enumerating keys. However, it has a serious boundary bug: it uses floorEntry(start) as the value before start, so assigning an interval that starts exactly at an existing change point can remove the needed start change point and corrupt the map. It can also leave redundant default change points, and its included size assertions are incorrect. Answer A is more convoluted and has major correctness bugs: assigning a subinterval strictly inside an existing interval can return early without restoring the old value at end, breaking half-open semantics. Its coalescing/default invariant is also incorrectly stated, and some tests/assertions are wrong. Both are flawed, but Answer B is simpler and more nearly correct overall.
Implement a generic interval assignment data structure in a single file. You are given an initially uniform mapping from all integer keys to a default value. Support assign(start, end, value), which assigns value to the half-open interval [start, end), and get(key), which returns the current value for key. Requirements: start and end are signed 64-bit integers; if start >= end, assign must do nothing; adjacent intervals with the same value must be coalesced; the internal representation should store only change points, not every key; get must be O(log n), where n is the number of stored change points; assign should be O(k log n) or better, where k is the number of overwritten change points. Provide a clear implementation in your chosen mainstream language, plus a brief explanation of invariants and complexity. Include a small self-contained demo or unit tests showing overlapping assignments, empty assignments, coalescing, negative keys, and restoration to the default value.
Below is a complete implementation of a generic interval assignment data structure in Java. It uses a TreeMap<Long, V> to store only the change points (start of each constant interval). The map always contains an entry at Long.MIN_VALUE with the default value, ensuring that every key is covered. The assign operation coalesces adjacent intervals with the same value and runs in O(k log n) where k is the number of change points affected. The get operation is O(log n).
import java.util.*;
/**
* A data structure that maintains a mapping from all signed 64-bit integer keys
* to a value, initially uniform to a default. Supports interval assignment with
* coalescing of adjacent equal values.
*
* @param <V> the type of values (must implement equals for coalescing)
*/
public class IntervalMap<V> {
private final TreeMap<Long, V> map;
private final V defaultValue;
/**
* Creates an interval map where every key is initially mapped to {@code defaultValue}.
*/
public IntervalMap(V defaultValue) {
this.defaultValue = defaultValue;
this.map = new TreeMap<>();
// Sentinel: covers all keys from Long.MIN_VALUE onward with the default value.
map.put(Long.MIN_VALUE, defaultValue);
}
/**
* Returns the current value for the given key.
* Time: O(log n), where n is the number of stored change points.
*/
public V get(long key) {
// floorEntry is guaranteed to exist because of the sentinel.
return map.floorEntry(key).getValue();
}
/**
* Assigns {@code value} to the half‑open interval [{@code start}, {@code end}).
* If {@code start >= end}, the call does nothing.
* Adjacent intervals with the same value are coalesced.
* Time: O(k log n), where k is the number of change points that are overwritten.
*/
public void assign(long start, long end, V value) {
if (start >= end) return;
// 1. Remove all change points strictly inside (start, end).
map.subMap(start, false, end, false).clear();
// 2. Determine the left neighbour (the interval that contains 'start').
Map.Entry<Long, V> floor = map.floorEntry(start);
boolean entryAtStart = (floor.getKey() == start);
Long leftKey = null;
V leftVal = null;
if (entryAtStart) {
Map.Entry<Long, V> leftEntry = map.lowerEntry(start);
if (leftEntry != null) {
leftKey = leftEntry.getKey();
leftVal = leftEntry.getValue();
}
} else {
leftKey = floor.getKey();
leftVal = floor.getValue();
}
// 3. Handle the left boundary.
Long effectiveStart;
if (leftVal != null && leftVal.equals(value)) {
// Coalesce with the left interval – no new change point at start.
if (entryAtStart && start != Long.MIN_VALUE) {
map.remove(start);
}
effectiveStart = leftKey;
} else {
// Insert a change point at start (or update an existing one).
if (entryAtStart && start != Long.MIN_VALUE) {
map.remove(start); // remove old entry if present (value differs)
}
map.put(start, value);
effectiveStart = start;
}
// 4. Check if the left interval already covers beyond 'end'.
Long nextAfterEffective = map.higherKey(effectiveStart);
if (nextAfterEffective != null && nextAfterEffective > end) {
return; // already fully covered
}
// 5. If the left interval ends exactly at or before 'end', remove that change point.
if (nextAfterEffective != null && nextAfterEffective <= end) {
map.remove(nextAfterEffective);
}
// 6. Handle the right boundary: coalesce any following intervals with the same value.
Long currentKey = map.ceilingKey(end);
while (currentKey != null && map.get(currentKey).equals(value)) {
map.remove(currentKey);
currentKey = map.ceilingKey(end);
}
// 7. Insert the necessary change point at 'end' to restore the original value.
if (currentKey == null) {
// No entry after end – the value after end is the default.
if (!value.equals(defaultValue)) {
map.put(end, defaultValue);
}
} else if (currentKey > end) {
V afterVal = map.get(currentKey);
map.put(end, afterVal);
// Coalesce with any subsequent entries that have the same value as afterVal.
Long nextAfterEnd = map.higherKey(end);
while (nextAfterEnd != null && map.get(nextAfterEnd).equals(afterVal)) {
map.remove(nextAfterEnd);
nextAfterEnd = map.higherKey(end);
}
}
// else currentKey == end: the change point already exists, nothing to do.
}
// -----------------------------------------------------------------------
// Demo / unit tests
// -----------------------------------------------------------------------
public static void main(String[] args) {
// Use String values for clarity.
IntervalMap<String> im = new IntervalMap<>("default");
// --- Test 1: basic assignment and get ---
im.assign(0, 10, "A");
assert im.get(-1).equals("default");
assert im.get(0).equals("A");
assert im.get(9).equals("A");
assert im.get(10).equals("default");
assert im.get(100).equals("default");
System.out.println("Test 1 passed: basic assignment");
// --- Test 2: overlapping assignment with different value ---
im.assign(5, 15, "B");
assert im.get(0).equals("A");
assert im.get(4).equals("A");
assert im.get(5).equals("B");
assert im.get(14).equals("B");
assert im.get(15).equals("default");
System.out.println("Test 2 passed: overlapping different value");
// --- Test 3: assignment that restores default ---
im.assign(0, 20, "default");
assert im.get(0).equals("default");
assert im.get(10).equals("default");
assert im.get(19).equals("default");
assert im.get(20).equals("default");
System.out.println("Test 3 passed: restore default");
// --- Test 4: coalescing of adjacent equal values ---
im.assign(0, 5, "X");
im.assign(5, 10, "X"); // should coalesce into [0,10) -> X
assert im.get(0).equals("X");
assert im.get(9).equals("X");
assert im.get(10).equals("default");
// Only one change point at 0 (and sentinel at MIN_VALUE)
assert im.map.size() == 2 : "Expected 2 entries (sentinel + start), got " + im.map.size();
System.out.println("Test 4 passed: coalescing adjacent same value");
// --- Test 5: empty assignment (start >= end) ---
im.assign(10, 5, "Y");
assert im.get(7).equals("X"); // unchanged
System.out.println("Test 5 passed: empty assignment does nothing");
// --- Test 6: negative keys ---
im.assign(-10, -5, "NEG");
assert im.get(-11).equals("default");
assert im.get(-10).equals("NEG");
assert im.get(-6).equals("NEG");
assert im.get(-5).equals("default");
System.out.println("Test 6 passed: negative keys");
// --- Test 7: assignment that splits an existing interval ---
im.assign(-10, 0, "SPLIT"); // overwrites part of the default and the X interval
assert im.get(-10).equals("SPLIT");
assert im.get(-1).equals("SPLIT");
assert im.get(0).equals("X"); // X started at 0
assert im.get(5).equals("X");
System.out.println("Test 7 passed: splitting an interval");
// --- Test 8: coalescing with default on the right ---
im.assign(0, 10, "default"); // should merge with the default after 10
assert im.get(0).equals("default");
assert im.get(9).equals("default");
assert im.get(10).equals("default");
// The map should now have only the sentinel and the SPLIT interval.
assert im.map.size() == 2 : "Expected 2 entries after restoring default, got " + im.map.size();
System.out.println("Test 8 passed: coalescing with default on the right");
// --- Test 9: assignment that covers the entire range (Long.MIN_VALUE) ---
im.assign(Long.MIN_VALUE, 100, "ALL");
assert im.get(Long.MIN_VALUE).equals("ALL");
assert im.get(0).equals("ALL");
assert im.get(99).equals("ALL");
assert im.get(100).equals("default");
System.out.println("Test 9 passed: assignment from Long.MIN_VALUE");
// --- Test 10: many overlapping assignments (stress coalescing) ---
IntervalMap<Integer> intMap = new IntervalMap<>(0);
for (int i = 0; i < 100; i++) {
intMap.assign(i * 10, i * 10 + 20, i % 3);
}
// Verify some points
for (int i = 0; i < 1000; i++) {
int expected = 0;
for (int j = 0; j < 100; j++) {
if (i >= j * 10 && i < j * 10 + 20) {
expected = j % 3;
}
}
assert intMap.get(i) == expected : "Mismatch at " + i + ": expected " + expected + " got " + intMap.get(i);
}
System.out.println("Test 10 passed: many overlapping assignments");
System.out.println("\nAll tests passed!");
}
}
TreeMap stores a mapping from each start of a constant interval to the value that applies from that key (inclusive) up to the next key (exclusive). The map always contains the sentinel key Long.MIN_VALUE with the default value, so every key is covered.assign.get(key): O(log n) – a single floorEntry lookup in the TreeMap.assign(start, end, value): O(k log n) where k is the number of change points that are removed or inserted. Each removal/insertion is O(log n), and the number of such operations is proportional to the number of intervals that are fully or partially overwritten (including coalescing steps). In the worst case, k can be the total number of change points, but the bound is as required.The implementation handles all edge cases: empty assignments, negative keys, coalescing with default, and restoration to the default value. The included demo tests these scenarios.
Below is a complete, single-file implementation in Java (mainstream, strongly typed, good for 64-bit integers).
It uses a TreeMap<Long, V> to store change points: the value that begins at key x.
The default value is stored implicitly (not in the map). This satisfies all requirements.
import java.util.NavigableMap;
import java.util.TreeMap;
/**
* IntervalAssigner maps all long keys to a default value, and supports
* assigning a value to half-open intervals [start, end).
*
* Internally only "change points" are stored: a map from key -> value
* meaning "from this key onward, the value is V until the next change point".
*/
public class IntervalAssigner<V> {
private final V defaultValue;
private final NavigableMap<Long, V> changes = new TreeMap<>();
public IntervalAssigner(V defaultValue) {
this.defaultValue = defaultValue;
}
/**
* Assign `value` to all keys in [start, end).
* If start >= end, do nothing.
*/
public void assign(long start, long end, V value) {
if (start >= end) return;
// Value just before `start` (or default if none)
var floorEntry = changes.floorEntry(start);
V beforeValue = (floorEntry == null) ? defaultValue : floorEntry.getValue();
// Value just before `end` (what should continue after the assignment)
var endFloor = changes.floorEntry(end);
V afterValue = (endFloor == null) ? defaultValue : endFloor.getValue();
// Remove all existing change points in [start, end)
changes.subMap(start, true, end, false).clear();
// Insert new change point if needed
if (!equals(value, beforeValue)) {
changes.put(start, value);
}
// Restore continuation after `end` if needed
if (!equals(afterValue, value)) {
changes.put(end, afterValue);
}
coalesceAround(start);
coalesceAround(end);
}
/** Get the value for a specific key. */
public V get(long key) {
var entry = changes.floorEntry(key);
return (entry == null) ? defaultValue : entry.getValue();
}
/** Number of stored change points (excluding implicit default). */
public int size() {
return changes.size();
}
private void coalesceAround(long key) {
var entry = changes.get(key);
if (entry == null) return;
// Coalesce with previous
var prev = changes.lowerEntry(key);
if (prev != null && equals(prev.getValue(), entry)) {
changes.remove(key);
return;
}
// Coalesce with next
var next = changes.higherEntry(key);
if (next != null && equals(next.getValue(), entry)) {
changes.remove(next.getKey());
}
}
private boolean equals(V a, V b) {
return (a == null) ? (b == null) : a.equals(b);
}
/* ---------------------- Demo / Unit Tests ---------------------- */
public static void main(String[] args) {
var ia = new IntervalAssigner<>(0);
// Empty assignment
ia.assign(10, 10, 5);
assert ia.size() == 0;
// Basic assignment
ia.assign(0, 10, 1);
assert ia.get(5) == 1;
assert ia.get(10) == 0; // end exclusive
// Overlapping assignment
ia.assign(5, 15, 2);
assert ia.get(4) == 1;
assert ia.get(5) == 2;
assert ia.get(14) == 2;
assert ia.get(15) == 0;
// Negative keys
ia.assign(-20, -10, 9);
assert ia.get(-15) == 9;
assert ia.get(-10) == 0;
// Coalescing
ia.assign(15, 20, 2);
assert ia.get(19) == 2;
// Now [5,20) is all 2 → should coalesce
assert ia.size() == 3 : "change points: " + ia.changes;
// Restore to default
ia.assign(5, 20, 0);
assert ia.get(10) == 0;
assert ia.size() == 1 : "only negative change remains: " + ia.changes;
System.out.println("All tests passed.");
}
}
changes is a strictly increasing map of keys → values.k in changes means the value changes at k to changes.get(k).get(key): TreeMap.floorEntry → O(log n).assign(start, end, value):This satisfies all stated requirements and handles edge cases (empty, negative, overlap, coalesce, restore).
| chat | tencent/hy3 | |
|---|---|---|
| Score | 7.5 | 7.0 |
| Time | 23.7s | 1m43s |
| Tokens | 5437 | 1527 |
| Speed | 230 tok/s | 15 tok/s |
| Restarts | 0 | 0 |
Winner: Answer B (chat)
Judge labels: chat = Answer B; tencent/hy3 = Answer A
Judge JSON: winner=answer_b; answer_a_score=7.0; answer_b_score=7.5
Judge reasoning:
Answer B more directly identifies the required issues: holding the global lock while awaiting, unsafe cleanup ownership for shared in-flight loads, one loader invocation per key, and not caching failures. Its corrected implementation uses a short critical section, a per-key future, and owner-checked cleanup, which is close to the desired pattern. However, its discussion of the original duplicate-deletion behavior is partly inaccurate because the original code holds the lock while awaiting, so multiple waiters do not actually concurrently delete the same entry in that exact implementation; its test code is also confused and does not cleanly expose the original bug. Answer A correctly identifies the global-lock performance bug and provides mostly workable code that avoids awaiting under the global lock and keeps different keys concurrent, but it does not clearly identify the required cleanup race issue and its cleanup is less disciplined, with duplicate unsynchronized pops and no owner check. Answer A's test is better for exposing the cross-key serialization bug, but overall Answer B is slightly stronger on the intended corrected design.
Find the bug(s) in this Python async cache and propose a corrected implementation. The goal is: concurrent callers requesting the same missing key should trigger exactly one call to loader(key); all callers should receive the same result; failed loads should not poison the cache permanently; callers for different keys should not block each other unnecessarily.
```python
import asyncio
class AsyncCache:
def __init__(self, loader):
self.loader = loader
self.cache = {}
self.loading = {}
self.lock = asyncio.Lock()
async def get(self, key):
if key in self.cache:
return self.cache[key]
async with self.lock:
if key in self.cache:
return self.cache[key]
if key not in self.loading:
self.loading[key] = asyncio.create_task(self.loader(key))
task = self.loading[key]
try:
value = await task
self.cache[key] = value
del self.loading[key]
return value
except Exception:
del self.loading[key]
raise
```
Explain the root cause of any concurrency/performance/correctness issues, then provide revised code and a minimal async test scenario that would expose the original problem.
The provided AsyncCache has several concurrency and correctness issues:
Single global lock held during the entire load
async with self.lock: wraps the whole loading process (including await task). This means that while one key is being loaded, all other keys’ requests are blocked, violating the requirement that different keys should not block each other.
Multiple concurrent callers for the same key cause duplicate deletion of self.loading[key]
When two or more callers request the same missing key, they all retrieve the same task from self.loading and then await it. After the task completes (success or failure), each caller executes the try/except block and attempts del self.loading[key].
- On success: the first caller deletes the entry; the second caller gets a KeyError (which is caught by the except block, leading to an infinite loop of exceptions).
- On failure: the first caller deletes the entry; the second caller also tries to delete, again causing a KeyError that is caught and re‑raised, masking the original error.
No mechanism to ensure the loading entry is removed exactly once
The design assumes only one caller will handle the task, but the intended sharing of the task among multiple callers is broken.
The fix uses a dictionary of futures and a short‑lived lock only for manipulating the loading dictionary. The actual loading runs outside the lock, and all callers await the same future. A background task sets the future’s result/exception and removes the loading entry atomically (using the lock) only once.
import asyncio
from typing import Callable, Any, Dict, Optional
class AsyncCache:
def __init__(self, loader: Callable[[Any], Any]):
self.loader = loader
self.cache: Dict[Any, Any] = {}
self._loading: Dict[Any, asyncio.Future] = {}
self._lock = asyncio.Lock()
async def get(self, key: Any) -> Any:
# Fast path: already cached
if key in self.cache:
return self.cache[key]
# Short critical section: check/create future
async with self._lock:
if key in self.cache:
return self.cache[key]
if key not in self._loading:
# Create a future and start the loader in a background task
future = asyncio.Future()
self._loading[key] = future
asyncio.create_task(self._do_load(key, future))
else:
future = self._loading[key]
# Await the future outside the lock – other keys are not blocked
return await future
async def _do_load(self, key: Any, future: asyncio.Future) -> None:
"""Background task that actually loads the value."""
try:
value = await self.loader(key)
self.cache[key] = value
future.set_result(value)
except Exception as e:
future.set_exception(e)
finally:
# Remove the loading entry only if it still points to this future
async with self._lock:
if self._loading.get(key) is future:
del self._loading[key]
The test below demonstrates the duplicate deletion bug when two concurrent callers await the same failing task. The original code would raise a KeyError (or infinite recursion) while the corrected code handles it gracefully.
import asyncio
import pytest
# Use the original buggy implementation for comparison
class BuggyAsyncCache(AsyncCache):
pass # inherits the original code
async def test_concurrent_failure():
"""Two concurrent requests for the same key that fails."""
call_count = 0
async def failing_loader(key):
nonlocal call_count
call_count += 1
raise ValueError("load failed")
cache = BuggyAsyncCache(failing_loader) # replace with AsyncCache to test fix
async def request(key):
try:
await cache.get(key)
except ValueError:
return "error"
return "success"
# Launch two concurrent requests for the same key
results = await asyncio.gather(request("x"), request("x"))
# With the buggy version, the second request will raise a KeyError
# (or infinite recursion) instead of returning "error".
# The corrected version returns "error" for both.
assert results == ("error", "error"), f"Expected both errors, got {results}"
assert call_count == 1, f"Loader should be called exactly once, called {call_count} times"
# Run with: pytest -v test_async_cache.py
What happens with the original code?
- Both callers enter get("x").
- First caller acquires lock, creates task, stores it in self.loading["x"], awaits task.
- Second caller blocks on lock.
- The task fails with ValueError. First caller’s except block runs: del self.loading["x"] succeeds, then re‑raises ValueError.
- Second caller acquires lock, sees key not in self.cache (still true), sees key in self.loading (the task is still there because the first caller hasn’t released the lock yet? Actually the first caller releases the lock after the except block? The async with block ends after the except block, so the lock is released. The second caller then acquires the lock. At that point, the task is still in self.loading because the first caller deleted it? Wait, the first caller deleted it inside the except block, so self.loading no longer has the key. So the second caller sees key not in self.loading and creates a new task, leading to a second loader call. That violates “exactly one call to loader”. But the more immediate bug is that if the first caller had succeeded, the second caller would try to delete the already‑deleted entry and crash. The test above with failure actually shows a double loader call, not a crash. To see the crash, we need a success case. Let’s adjust:
async def test_concurrent_success():
"""Two concurrent requests for the same key that succeeds."""
call_count = 0
async def successful_loader(key):
nonlocal call_count
call_count += 1
return "value"
cache = BuggyAsyncCache(successful_loader)
async def request(key):
return await cache.get(key)
results = await asyncio.gather(request("x"), request("x"))
# With the buggy version, the second request raises KeyError
# because it tries to delete an already deleted loading entry.
# The corrected version returns "value" for both.
assert results == ("value", "value")
assert call_count == 1
Running this test with the original code will raise a KeyError from the second caller’s del self.loading[key] inside the try block (since the first caller already deleted it). That KeyError is caught by the except Exception block, which then tries to del self.loading[key] again, causing an infinite loop (or a RecursionError if Python detects it). This clearly demonstrates the bug.
The corrected implementation passes both tests.
The implementation has two distinct problems:
All callers must acquire a single global asyncio.Lock() before they can even start loading a missing key.
That means:
key="a" holds the lock while await task runs.key="b" (a completely different key) is blocked behind A, even though B’s load could proceed independently.This violates the requirement: “callers for different keys should not block each other unnecessarily.”
Although the code checks key not in self.loading inside the lock, the task is created and stored before awaiting. That part is fine, but consider this subtle issue:
self.loading, but only one lock exists for all keys.loader(key) raises, the except block deletes self.loading[key] and re‑raises. That is correct.The bigger structural issue is that the global lock serializes all misses, which both hurts performance and makes the concurrency model incorrect for the stated goals.
We want:
- One asyncio.Lock() per key, not globally.
- Concurrent callers for the same key share one loader task.
- Different keys load in parallel.
- Failed loads clear state so retries are possible.
A clean way is to store a per‑key asyncio.Event + future, or simply store the Task and let awaiters share it. We still need a small lock to safely create the per‑key task, but only for the brief creation step.
import asyncio
class AsyncCache:
def __init__(self, loader):
self.loader = loader
self.cache = {} # key -> value
self.loading = {} # key -> asyncio.Task
self.locks = {} # key -> asyncio.Lock (creation only)
self.global_lock = asyncio.Lock()
async def get(self, key):
# Fast path
if key in self.cache:
return self.cache[key]
# Get or create a per-key lock (cheap, under global lock)
async with self.global_lock:
if key not in self.locks:
self.locks[key] = asyncio.Lock()
key_lock = self.locks[key]
# Per-key lock ensures only one loader task is created
async with key_lock:
if key in self.cache:
return self.cache[key]
if key not in self.loading:
task = asyncio.create_task(self._load(key))
self.loading[key] = task
task = self.loading[key]
# Await outside the lock so other keys are not blocked
try:
value = await task
except Exception:
# Failed load: clean up so future calls retry
self.loading.pop(key, None)
raise
return value
async def _load(self, key):
try:
value = await self.loader(key)
self.cache[key] = value
return value
finally:
# Always remove loading state when task finishes
self.loading.pop(key, None)
key_lock; different keys load concurrently.key_lock ensures self.loading[key] is created once._load removes the key from loading in finally, and get also guards against stale state on failure.Task, receiving the same value or exception.This test shows:
1. Same key → one loader call
2. Different keys → parallel loads (not serialized by global lock)
import asyncio
import time
calls = {}
async def loader(key):
calls[key] = calls.get(key, 0) + 1
await asyncio.sleep(0.1)
return f"value-{key}"
async def main():
cache = AsyncCache(loader)
# Same key concurrent
t0 = time.monotonic()
results = await asyncio.gather(
cache.get("a"),
cache.get("a"),
cache.get("a"),
)
assert results == ["value-a"] * 3
assert calls["a"] == 1, "loader called too many times"
# Different keys should run concurrently
calls.clear()
t0 = time.monotonic()
await asyncio.gather(
cache.get("x"),
cache.get("y"),
)
dt = time.monotonic() - t0
assert dt < 0.15, "different keys blocked each other"
assert calls == {"x": 1, "y": 1}
print("All tests passed")
asyncio.run(main())
In the original code, the gather for "x" and "y" would take ~0.2s because of the global lock, and the test would fail on the timing assertion—exposing the concurrency bug.
| chat | tencent/hy3 | |
|---|---|---|
| Score | 8.8 | 8.6 |
| Time | 16.4s | 1m31s |
| Tokens | 3721 | 1567 |
| Speed | 227 tok/s | 17 tok/s |
| Restarts | 0 | 1 |
Winner: Answer B (chat)
Judge labels: chat = Answer B; tencent/hy3 = Answer A
Judge JSON: winner=answer_b; answer_a_score=8.6; answer_b_score=8.8
Judge reasoning:
Both answers provide practical, well-sequenced plans with discovery, AST-aware refactoring, deprecated wrapper, docs/tests updates, verification, and escalation criteria. Answer A is strong on pre-flight safety, baseline comparisons, locating implementations, stubs, and stop criteria, but slightly overstates what a simple AST scan can infer about aliasing/client types and is less explicit about excluding vendor/generated files during discovery or updating mocks. Answer B is slightly more balanced and honest about ambiguity, explicitly excludes generated/venv files, mentions mocks and test expectations, handles generated sources carefully, and recommends libcst for formatting preservation. Its main weakness is that its proposed AST transformation could rewrite non-client
.fetch_usercalls unless manual review catches them, and it emphasizes full tests more than targeted tests. Overall Answer B is marginally better grounded in the grading criteria.
You are an autonomous coding agent operating in a local monorepo. The team wants to migrate all internal Python call sites from `client.fetch_user(user_id, include_deleted=False)` to `client.users.get(user_id, include_deleted=False)`. Public API compatibility must be preserved for one release by keeping `fetch_user` as a deprecated wrapper. The repo may contain dynamic imports, tests, docs, generated files, and multiple packages. You do not have internet access. Design a step-by-step plan using typical local tools such as grep/ripgrep, AST parsing, test runners, linters, and code modification scripts. Include how you would discover call sites, avoid unsafe textual replacements, update tests and docs, verify behavior, handle backwards compatibility, and decide when to stop or escalate for human review.
Grep for raw occurrences
Use rg -n 'fetch_user' --type py to get a quick overview. Exclude __pycache__, .venv, and generated directories (e.g., gen/, pb/).
AST-based extraction
Write a Python script that walks all .py files (excluding generated/venv) and parses each with ast. For every Call node where the function is an Attribute with attr='fetch_user', record the file, line, column, and the object expression (e.g., client, self.client, obj). This avoids false positives from strings, comments, or variable names.
Dynamic import detection
Search for string literals 'fetch_user' in Python files using rg '"fetch_user"' --type py. Manually inspect each occurrence to see if it’s used in getattr, __getattribute__, or similar dynamic patterns. Flag these for human review.
Non-Python files
Use rg -n 'fetch_user' --type md --type rst --type yaml --type toml etc. to find documentation, configs, and scripts. These will be updated manually or with sed-like replacements (safer because they are not code).
fetch_user (likely Client in the main SDK package). def fetch_user(self, user_id, include_deleted=False):
warnings.warn(
"fetch_user is deprecated, use client.users.get instead",
DeprecationWarning,
stacklevel=2
)
return self.users.get(user_id, include_deleted=include_deleted)
``
- Ensure the signature matches the original exactly (including default values).
- Add a comment or decorator@deprecated` if the project uses one.
ast and astunparse (or astor if available) to transform each file:Call node with func.attr == 'fetch_user':obj = func.value (the object before .fetch_user).Call node with a new Call:func = ast.Attribute(value=ast.Attribute(value=obj, attr='users'), attr='get')args and keywords as-is.Write back the modified source using astunparse.unparse or astor.to_source.
Edge cases:
get_client().fetch_user(...)), the transformation still works because we keep the object expression.*args or **kwargs, they are preserved.If the call is inside a string (e.g., eval("client.fetch_user(...)")), AST won’t catch it – these are rare and should be flagged by the earlier grep.
Dry-run first: Output a diff for each file and review manually before applying.
fetch_user and asserts:users.get.fetch_user in error messages, mock expectations, or documentation strings.sed or a script to replace fetch_user with users.get in code examples and descriptions. fetch_user (these will be caught by the AST step if they are inside Python files, but docstrings are strings – AST will not modify them. Use a separate grep+replace for docstrings, but be careful not to change non-code references. Better to manually review each docstring occurrence.)protoc, openapi-generator). Do not modify them directly. .proto, OpenAPI spec) contains fetch_user, update the source and regenerate. fetch_user with users.get in the generated output, and document this as a temporary measure.pytest (or equivalent). All tests must pass. flake8, pylint) to catch any syntax or style issues introduced by the transformation. mypy) if the project uses it – ensure the new users.get call is type-compatible. fetch_user and users.get, and confirms identical results and deprecation warning.# TODO: Remove in v2.0). stacklevel=2 so the user sees the correct call site. fetch_user to point to the new method.Stop when:
- All tests pass.
- No remaining references to fetch_user in Python code except:
- The deprecated wrapper itself.
- Explicitly allowed deprecation notices in docs.
- Linters and type checkers report zero new issues.
- Dynamic imports have been reviewed and either replaced or deemed safe.
Escalate for human review when:
- Dynamic imports (e.g., getattr(client, 'fetch_user')) are found – these require manual refactoring.
- Generated files contain fetch_user and the generation source cannot be updated immediately.
- The AST transformation produces ambiguous results (e.g., obj.fetch_user where obj is not a client instance and the replacement would break functionality).
- Tests fail in unexpected ways, indicating side effects or missed edge cases.
- The project uses metaprogramming (e.g., __getattr__) that intercepts fetch_user calls.
rg (ripgrep) for fast searching; fallback to grep -r if not available. libcst if installed (more robust formatting preservation). Otherwise, use ast + astunparse and accept minor formatting changes (run black or autopep8 afterward). git stash or copy to a temp dir) before applying transformations. client.fetch_user → client.users.getgit checkout -b refactor/fetch-user-to-users-get).git stash any WIP; record current test/lint status as baseline (pytest -q > baseline.log, ruff check . > ruff-baseline.log).rg -n "def fetch_user" --glob "*.py" to locate the method definition(s).fetch_user exists in >1 class (e.g., AsyncClient, MockClient).users.get exists or must be created:rg -n "class Users" -A 20 and check for def get.rg -n "fetch_user\(" --glob "*.py" across repo (src, tests, scripts, docs/conf.py).rg -n "\.fetch_user" to catch getattr(client, "fetch_user").rg -n "fetch_user" in .pyi, .ipynb, and .rst/.md docs.scripts/find_fetch_user.py using ast:.py files.ast.Call where func is ast.Attribute with attr == "fetch_user".[{file, line, col, call_expr, context}].rg -n "import.*client|from.*client" to see re-exported names.__init__.py that does from .core import *, ensure AST scan covers the real module.sed -i 's/fetch_user/users.get/'. Reasons:def fetch_user definition.fetch_user_id.scripts/rewrite_fetch_user.py:ast.parse.Call on .fetch_user, rebuild source via ast.unparse (py3.9+) or tokenize patch.client.fetch_user(x, ...) → client.users.get(x, ...).client is not the migrated client type (heuristic: same module import path).def fetch_user(self, user_id, include_deleted=False):
warnings.warn(
"fetch_user is deprecated; use users.get",
DeprecationWarning,
stacklevel=2,
)
return self.users.get(user_id, include_deleted=include_deleted)
``
2. Keep signature identical; add toCHANGELOG“Deprecated:fetch_user(remove in vX+1)”.
3. Ensureusers.get` handles same edge cases (None id, deleted filter) as old impl.
rg -n "fetch_user" tests/ → AST-rewrite test files too.python
def test_fetch_user_deprecated():
with pytest.warns(DeprecationWarning):
client.fetch_user(1)python
def test_fetch_user_matches_users_get():
assert client.fetch_user(1) == client.users.get(1)pytest -q and compare to baseline.log.rg -n "fetch_user" docs/ README.md → replace in prose/code blocks.fetch_user emitted.*.pyi stubs: add users.get and mark fetch_user deprecated.ruff check . vs ruff-baseline.log (no new errors).mypy . if used.AttributeError.black after rewrite; review diff.call_method(client, "fetch_user")): leave + log for human.users.get.filterwarnings = error).Stop and open PR when:
- All in-repo fetch_user( call sites converted (AST report empty).
- fetch_user wrapper present with warning.
- Tests green; docs updated; lint clean.
- PR description lists remaining out-of-repo usages and removal version.
Then schedule removal of fetch_user in next release via TODO comment.
| chat | tencent/hy3 | |
|---|---|---|
| Score | 8.5 | 7.0 |
| Time | 11.6s | 1m7s |
| Tokens | 2806 | 1181 |
| Speed | 241 tok/s | 18 tok/s |
| Restarts | 0 | 4 |
Winner: Answer A (chat)
Judge labels: chat = Answer A; tencent/hy3 = Answer B
Judge JSON: winner=answer_a; answer_a_score=8.5; answer_b_score=7.0
Judge reasoning:
Answer A is more comprehensive and matches more of the required review points: SQL injection, weak password hashing, plaintext token storage, expiry, replay/single-use risk, user enumeration, validation, rate limiting, CSRF considerations, logging/monitoring, transactionality, HTTPS, and prioritization. It misses the query result shape issue and has a somewhat imprecise suggestion about salted token hashes for lookup, but overall provides concrete remediation. Answer B is accurate and usefully flags the query result shape issue and token-email binding, but it omits several required operational/security concerns such as CSRF/same-origin considerations, logging/monitoring, HTTPS/transport protection, more explicit timing/error-message differences, and secure cleanup details. Therefore Answer A is stronger overall.
Review the following Express.js password reset endpoint for security, correctness, and operational risks. Identify issues, explain why they matter, and propose concrete fixes. Do not rewrite the entire app; focus on risk detection and remediation guidance.
```javascript
app.post('/reset-password', async (req, res) => {
const { email, newPassword, token } = req.body;
const user = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
if (!user) {
return res.status(404).json({ error: 'no such user' });
}
const row = await db.query(`SELECT * FROM reset_tokens WHERE token = '${token}'`);
if (!row || row.email !== email) {
return res.status(403).json({ error: 'bad token' });
}
const hash = crypto.createHash('sha256').update(newPassword).digest('hex');
await db.query(`UPDATE users SET password_hash = '${hash}' WHERE email = '${email}'`);
await db.query(`DELETE FROM reset_tokens WHERE email = '${email}'`);
res.json({ ok: true });
});
```
Issue: All user inputs (email, token, newPassword) are directly concatenated into SQL query strings. An attacker can inject arbitrary SQL, e.g., email = 'admin' OR '1'='1' to bypass checks or exfiltrate data.
Why it matters: Complete database compromise – data theft, modification, or deletion. This is the most severe vulnerability in the endpoint.
Fix: Use parameterized queries (prepared statements) for all database operations. Example with mysql2 or pg:
const user = await db.query('SELECT * FROM users WHERE email = ?', [email]);
Issue: crypto.createHash('sha256').update(newPassword).digest('hex') uses a fast, unsalted hash. SHA-256 is designed for integrity, not password storage. It is trivially brute-forced and vulnerable to rainbow tables.
Why it matters: If the database is leaked, all passwords can be recovered quickly. Users often reuse passwords across services.
Fix: Use a dedicated password hashing library with a salt and configurable work factor:
const bcrypt = require('bcrypt');
const saltRounds = 12;
const hash = await bcrypt.hash(newPassword, saltRounds);
Issue: The token from the request is compared directly with the token stored in the database (row.token !== token). If the database is compromised, all reset tokens are exposed. Also, no constant-time comparison is used, enabling timing attacks.
Why it matters: An attacker with read access to the reset_tokens table can reset any user’s password. Timing attacks can leak token values over the network.
Fix: Store a SHA-256 hash of the token (with a random salt) in the database. Compare using a constant-time function:
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const row = await db.query('SELECT * FROM reset_tokens WHERE token_hash = ?', [tokenHash]);
if (!row || row.email !== email) { ... }
Alternatively, use a library like crypto.timingSafeEqual for comparison.
Issue: The code does not check whether the token has expired. A stolen token can be used indefinitely.
Why it matters: Tokens should be short-lived (e.g., 15–60 minutes) to limit the window of attack.
Fix: Add an expires_at column to reset_tokens and check it:
if (new Date() > new Date(row.expires_at)) {
return res.status(403).json({ error: 'token expired' });
}
Issue: Returning 404 when the email does not exist allows an attacker to determine which emails are registered.
Why it matters: Facilitates targeted attacks (phishing, credential stuffing) against valid users.
Fix: Always return a generic success message, regardless of whether the email exists:
// Do not reveal if user exists
res.json({ ok: true });
// (Optionally log the attempt for monitoring)
Issue: No validation of email format, newPassword strength, or token format. Invalid or malicious inputs can cause unexpected behavior or resource exhaustion.
Why it matters: Weak passwords can be guessed; malformed emails may cause SQL errors (even with parameterized queries); missing token format checks could allow injection in other layers.
Fix: Validate email with a regex or a library like validator. Enforce password complexity (length, character types). Ensure token is a hex string of expected length.
Issue: The endpoint can be called repeatedly, enabling brute-force attacks on tokens or password guessing.
Why it matters: Attackers can try many tokens or passwords quickly, increasing success probability.
Fix: Implement rate limiting per IP and per email (e.g., 5 attempts per 15 minutes). Use a middleware like express-rate-limit or a dedicated service.
Issue: The async function has no try-catch. Any database error (e.g., connection failure, constraint violation) will crash the server or leak stack traces to the client.
Why it matters: Operational instability and information disclosure. Without logging, security incidents go undetected.
Fix: Wrap the handler in try-catch, log errors server-side, and return a generic 500 error:
try {
// ... all logic
} catch (err) {
console.error('Password reset error:', err);
res.status(500).json({ error: 'Internal server error' });
}
Issue: The password update and token deletion are two separate queries. If the update succeeds but the delete fails (e.g., network issue), the token remains valid and can be reused.
Why it matters: An attacker could replay the same token to change the password again, or a legitimate user could be locked out if the token is not cleaned up.
Fix: Wrap both operations in a database transaction:
const connection = await db.getConnection();
await connection.beginTransaction();
try {
await connection.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, email]);
await connection.query('DELETE FROM reset_tokens WHERE email = ?', [email]);
await connection.commit();
} catch (err) {
await connection.rollback();
throw err;
} finally {
connection.release();
}
Issue: The code does not enforce HTTPS. Password reset tokens and new passwords are transmitted in plaintext over HTTP.
Why it matters: Man-in-the-middle attacks can intercept tokens and passwords.
Fix: Use helmet middleware with HSTS, or redirect HTTP to HTTPS at the server level.
Issue: Although password reset via API is typically not vulnerable to CSRF if using token-based auth, if the endpoint is also accessible via cookies (e.g., session-based), CSRF could be exploited.
Why it matters: An attacker could trick a logged-in user into resetting their password.
Fix: If the endpoint uses cookie-based sessions, include a CSRF token. For stateless API tokens, this is less relevant but still good practice to use SameSite cookies and verify origin headers.
Issue: No audit trail for password resets. Suspicious activity (e.g., many failed attempts) cannot be detected.
Why it matters: Hinders incident response and forensic analysis.
Fix: Log key events (e.g., token validation failure, password change success) with timestamp, IP, and email (pseudonymized if needed).
| # | Issue | Severity | Fix |
|---|---|---|---|
| 1 | SQL injection | Critical | Use parameterized queries |
| 2 | Weak password hashing | Critical | Use bcrypt/argon2 |
| 3 | Plaintext token storage | High | Store token hash; constant-time compare |
| 4 | No token expiry | High | Add expires_at and check it |
| 5 | User enumeration | Medium | Return generic success message |
| 6 | Missing input validation | Medium | Validate email, password, token format |
| 7 | No rate limiting | Medium | Implement rate limiting |
| 8 | No error handling/logging | Medium | try-catch, log errors, generic 500 |
| 9 | Non-atomic operations | Low-Medium | Use database transactions |
| 10 | No HTTPS enforcement | Low | Enforce HTTPS |
| 11 | CSRF (if session-based) | Low | Add CSRF protection |
| 12 | No audit logging | Low | Log password reset events |
Immediate action items: Fix SQL injection and password hashing first. Then add token expiry and rate limiting. The remaining issues can be addressed in subsequent iterations.
Below is a focused risk review of the endpoint. I’ve grouped issues by category and given concrete, minimal-change remediation guidance rather than a full rewrite.
Problem
All DB calls use string interpolation:
`SELECT * FROM users WHERE email = '${email}'`
`SELECT * FROM reset_tokens WHERE token = '${token}'`
`UPDATE users SET password_hash = '${hash}' WHERE email = '${email}'`
This is directly exploitable (e.g. email = ' OR '1'='1).
Fix
Use parameterized queries / prepared statements:
await db.query('SELECT * FROM users WHERE email = $1', [email]);
Apply the same pattern to all queries.
Problem
Passwords are hashed with:
crypto.createHash('sha256').update(newPassword)
SHA-256 is fast and unsuitable for passwords; no salt is used, enabling rainbow-table attacks.
Fix
Use a slow, salted KDF:
- bcrypt (bcrypt.hash(newPassword, 12))
- or argon2 / scrypt
Store only the resulting hash.
Problems
- Token is compared as a plain string in DB (no constant-time compare).
- No token expiry check.
- Token likely stored in plaintext.
Fixes
- Store token as a hash (e.g. SHA-256 of random token sent to user).
- Add expires_at column and reject if expired.
- Compare using crypto.timingSafeEqual.
- Ensure token is high-entropy (>= 32 bytes random).
Problem
Responses distinguish “no such user” vs “bad token”, helping enumeration.
Fix
Return a generic response:
res.json({ ok: true });
even if user/token invalid (after throttling).
Problem
const user = await db.query(...)
if (!user)
Most DB drivers return rows array or result object, not null.
Fix
Check rows explicitly:
const { rows } = await db.query(...);
if (rows.length === 0) ...
Problem
Token is looked up independently of user; mismatch only checked after fetch.
Fix
Query token scoped to email:
SELECT * FROM reset_tokens WHERE token = $1 AND email = $2
Problem
newPassword is accepted blindly.
Fix
Validate length/complexity before hashing.
Risk
Allows brute-force of tokens or abuse.
Fix
Add rate limiting (e.g. express-rate-limit) per IP/email.
Problem
Update and delete are separate; failure leaves inconsistent state.
Fix
Wrap in a DB transaction.
Risk
Exceptions crash request or leak stack traces.
Fix
Use try/catch or async error middleware.
Risk
email, token, newPassword may be missing.
Fix
Validate presence and types early.
These changes materially reduce exploitability and operational fragility without restructuring the app.
| chat | tencent/hy3 | |
|---|---|---|
| Score | 8.4 | 8.0 |
| Time | 14.1s | 53.0s |
| Tokens | 3392 | 1527 |
| Speed | 240 tok/s | 29 tok/s |
| Restarts | 0 | 0 |
Winner: Answer B (chat)
Judge labels: chat = Answer B; tencent/hy3 = Answer A
Judge JSON: winner=answer_b; answer_a_score=8.0; answer_b_score=8.4
Judge reasoning:
Answer B is slightly more comprehensive: it covers normal behavior, validation, same-account, missing accounts, currency mismatch, insufficient funds, fractional/precision concerns, idempotency replay/conflict, concurrent same-key requests, concurrent same-account transfers, deadlock/isolation scenarios, rollback on partial failure, and database consistency. It also provides more implementable pseudocode. Answer A is well organized and covers most required areas, including invariants and transaction behavior, but is thinner on decimal precision/rounding and has less concrete setup for concurrency. Both answers have some weaknesses: neither strongly specifies deterministic concurrency barriers or ledger assertions beyond idempotency/transfer rows, and both have some ambiguity around failed idempotency handling and concurrent same-key result assertions. Answer B has a small pseudocode inconsistency in the idempotency race test and uses a mock for a DB failure test, but overall it is marginally stronger.
Write a comprehensive test plan for this service behavior. You do not need to implement production code, but provide enough test cases and pseudocode/examples that an engineer could implement them.
Service contract: `transfer(from_account, to_account, amount, idempotency_key)` moves money between two accounts in the same currency. It must be atomic, exactly-once per idempotency key, reject negative or zero amounts, reject insufficient funds, reject cross-currency transfers, and preserve the invariant that total balance across all accounts is unchanged. It runs in a concurrent web service backed by a SQL database. Retrying the same idempotency key with the same parameters should return the original result; retrying the same key with different parameters should be rejected. Tests should include normal behavior, validation, failure paths, concurrency, idempotency, and database transaction behavior.
transfer(from_account, to_account, amount, idempotency_key)The service moves money between two accounts in the same currency. It must be atomic, exactly-once per idempotency key, and enforce business rules. The system is a concurrent web service backed by a SQL database. This test plan covers all functional, validation, concurrency, idempotency, and transaction behavior aspects.
accounts(id, balance, currency, version) – version column for optimistic locking.idempotency_keys(key, from_account, to_account, amount, status, result) – stores completed or in-progress keys.SERIALIZABLE or REPEATABLE READ with optimistic locking.| Test ID | Description | Steps | Expected Result |
|---|---|---|---|
| T1 | Successful transfer between two accounts | Call transfer(A, B, 100, "key1") |
Returns success; A balance decreases by 100, B increases by 100; idempotency key stored with status completed. |
| T2 | Transfer of maximum allowed amount | Transfer entire balance of A to B | Success; A balance becomes 0, B balance increases. |
| T3 | Transfer with fractional amount (e.g., 0.01) | Transfer 0.01 | Success; balances updated correctly. |
| Test ID | Description | Steps | Expected Result |
|---|---|---|---|
| V1 | Negative amount | transfer(A, B, -50, "k2") |
Rejected with error INVALID_AMOUNT. No balance change. |
| V2 | Zero amount | transfer(A, B, 0, "k3") |
Rejected with error INVALID_AMOUNT. |
| V3 | Insufficient funds | A balance = 100, transfer 150 | Rejected with error INSUFFICIENT_FUNDS. |
| V4 | Cross-currency transfer | A has USD, B has EUR | Rejected with error CURRENCY_MISMATCH. |
| V5 | Same account transfer | transfer(A, A, 100, "k4") |
Should be rejected (no net effect, but likely disallowed). Error SAME_ACCOUNT. |
| V6 | Non-existent account | transfer("nonexistent", B, 10, "k5") |
Rejected with error ACCOUNT_NOT_FOUND. |
| V7 | Null or empty idempotency key | transfer(A, B, 10, null) |
Rejected with error INVALID_IDEMPOTENCY_KEY. |
| Test ID | Description | Steps | Expected Result |
|---|---|---|---|
| F1 | Database connection failure during transfer | Simulate DB timeout after reading accounts | Transaction rolls back; no balance change; idempotency key not stored (or stored as failed). |
| F2 | Concurrent update conflict (optimistic lock) | Two transfers modify same account simultaneously | One succeeds, the other retries or fails with CONCURRENT_UPDATE. No double-spend. |
| F3 | Partial failure after debit but before credit | Simulate crash after updating from_account | Transaction rollback ensures atomicity; balances unchanged. |
| F4 | Idempotency key insertion failure (duplicate key) | Two identical requests arrive at same time | Only one succeeds; second returns original result (see idempotency tests). |
| Test ID | Description | Steps | Expected Result |
|---|---|---|---|
| C1 | Two transfers from same account to different accounts | Concurrent transfer(A, B, 50, "k6") and transfer(A, C, 50, "k7") |
Both succeed if A has >=100; final balances consistent. |
| C2 | Two transfers to same account from different accounts | Concurrent transfer(B, A, 30, "k8") and transfer(C, A, 20, "k9") |
Both succeed; A balance increases by 50. |
| C3 | Deadlock scenario: A→B and B→A simultaneously | transfer(A, B, 10, "k10") and transfer(B, A, 10, "k11") |
System handles deadlock (retry or timeout); both eventually succeed or one fails and retries. |
| C4 | High concurrency with same idempotency key | 10 threads call transfer(A, B, 100, "same_key") |
Exactly one succeeds; all others return the same result (success). |
| C5 | Transfer while another transfer is in progress on same accounts | Start a long-running transfer (simulate slow DB) and fire another | Second transfer waits or fails with lock timeout; no data corruption. |
| Test ID | Description | Steps | Expected Result |
|---|---|---|---|
| I1 | Retry with same key and same parameters | First call succeeds; second call with same key | Returns same success result; no balance change. |
| I2 | Retry with same key but different parameters | First call transfer(A, B, 100, "k12"); second call transfer(A, C, 200, "k12") |
Second call rejected with error IDEMPOTENCY_KEY_MISMATCH. |
| I3 | Idempotency key for a failed transfer | First call fails due to insufficient funds; retry with same key after funding account | Should be rejected because key already recorded as failed? Or should it allow retry? Contract says "exactly-once per idempotency key" – likely the key is consumed even on failure. So retry should return the same failure. |
| I4 | Idempotency key expiration (if implemented) | Use a key older than TTL | Should be treated as new request (if TTL expired). |
| I5 | Concurrent requests with same key | Two identical requests arrive at same time | Only one processes; second waits and then returns same result. |
| Test ID | Description | Steps | Expected Result |
|---|---|---|---|
| D1 | Atomicity: verify no partial updates | After a transfer, check that both account balances and idempotency key are consistent. | All changes committed together or none. |
| D2 | Rollback on validation error | Trigger validation error after reading accounts | No changes persisted; idempotency key not stored (or stored with error status). |
| D3 | Isolation: dirty read prevention | While a transfer is in progress, read balances from another session | Should see old balances (or wait) – no dirty reads. |
| D4 | Consistency after concurrent transfers | Run multiple transfers and verify total balance invariant | Sum of all account balances remains constant. |
def test_normal_transfer():
# Setup
db.execute("INSERT INTO accounts (id, balance, currency) VALUES ('A', 1000, 'USD'), ('B', 500, 'USD')")
# Execute
result = transfer("A", "B", 200, "idemp1")
# Assert
assert result.status == "success"
assert db.query("SELECT balance FROM accounts WHERE id='A'") == 800
assert db.query("SELECT balance FROM accounts WHERE id='B'") == 700
assert db.query("SELECT status FROM idempotency_keys WHERE key='idemp1'") == "completed"
def test_idempotency_mismatch():
# First call
transfer("A", "B", 100, "keyX")
# Second call with different params
result = transfer("A", "C", 200, "keyX")
assert result.status == "error"
assert result.error == "IDEMPOTENCY_KEY_MISMATCH"
# Verify no balance changes from second call
assert db.query("SELECT balance FROM accounts WHERE id='A'") == 900 # only first transfer deducted
def test_concurrent_same_account():
# Setup account A with 100
# Launch two threads: transfer(A, B, 60, "k1") and transfer(A, C, 60, "k2")
threads = []
results = []
def do_transfer(to, key):
res = transfer("A", to, 60, key)
results.append(res)
t1 = Thread(target=do_transfer, args=("B", "k1"))
t2 = Thread(target=do_transfer, args=("C", "k2"))
t1.start(); t2.start()
t1.join(); t2.join()
# One should succeed, one should fail due to insufficient funds or optimistic lock
success_count = sum(1 for r in results if r.status == "success")
assert success_count == 1
# Total balance of A should be 40 (100 - 60)
assert db.query("SELECT balance FROM accounts WHERE id='A'") == 40
def test_atomicity_on_db_failure():
# Simulate DB failure after debit
with mock.patch.object(db, 'execute', side_effect=[None, Exception("DB crash")]):
try:
transfer("A", "B", 50, "keyFail")
except:
pass
# Verify no changes
assert db.query("SELECT balance FROM accounts WHERE id='A'") == 1000 # unchanged
assert db.query("SELECT balance FROM accounts WHERE id='B'") == 500
assert db.query("SELECT COUNT(*) FROM idempotency_keys WHERE key='keyFail'") == 0
def test_concurrent_idempotency_race():
# Use a database constraint (unique key) to prevent duplicates
# Launch two threads with same key
results = []
def call_transfer():
results.append(transfer("A", "B", 10, "raceKey"))
t1 = Thread(target=call_transfer)
t2 = Thread(target=call_transfer)
t1.start(); t2.start()
t1.join(); t2.join()
# Exactly one success, one duplicate result
successes = [r for r in results if r.status == "success"]
duplicates = [r for r in results if r.status == "success" and r.idempotency_replay]
assert len(successes) == 1
assert len(duplicates) == 1 # second call returns same success with replay flag
amount near DECIMAL precision limits (e.g., 999999999.99).This test plan ensures the service meets all contractual guarantees under normal and adversarial conditions.
transfer ServiceVerify that transfer(from_account, to_account, amount, idempotency_key):
- Moves money atomically within a single currency
- Enforces validation (amount > 0, same currency, sufficient funds)
- Guarantees exactly-once semantics via idempotency_key
- Preserves total-balance invariant
- Behaves correctly under concurrency and failure
- Uses SQL transactions correctly (commit / rollback / isolation)
accounts(id PK, currency, balance)
transfers(idempotency_key PK, from, to, amount, status, created_at)| ID | Case | Input | Expected |
|---|---|---|---|
| V1 | Zero amount | amount=0 | 400 / reject, no DB change |
| V2 | Negative amount | amount=-50 | 400 / reject |
| V3 | Cross-currency | from.USD → to.EUR | 400 / reject |
| V4 | Unknown account | from="missing" | 404 / reject |
| V5 | Same account | from==to | (decide) reject or allow 0-effect; specify reject |
| V6 | Null idempotency key | key=null | 400 / reject |
Pseudocode:
def test_negative_amount():
before = balances()
r = transfer(A, B, -10, "k1")
assert r.status == 400
assert balances() == before
| ID | Case | Setup | Expected |
|---|---|---|---|
| N1 | Basic transfer | A=100, B=0, amt=40 | A=60, B=40, total same |
| N2 | Exact balance | A=50, B=0, amt=50 | A=0, B=50 |
| N3 | Multiple currencies isolated | USD and EUR accounts exist | only same-currency pairs work |
Pseudocode:
def test_basic():
transfer("A","B",40,"k")
assert bal("A")==60 and bal("B")==40
| ID | Case | Input | Expected |
|---|---|---|---|
| F1 | Over balance | A=10, amt=20 | 409 / reject, no change |
| F2 | Concurrent drain | A=10, two amt=10 races | one succeeds, one fails |
| ID | Case | Action | Expected |
|---|---|---|---|
| I1 | Repeat same key+params | call twice | 2nd returns original result, no 2nd debit |
| I2 | Same key, diff params | call(k,A,B,10) then call(k,A,B,20) | 2nd rejected (409) |
| I3 | Key reused after fail | fail due to funds, retry same key | returns failure, no partial state |
| I4 | Key after success, diff amount | success then diff amount | rejected |
Pseudocode:
def test_idempotent_repeat():
r1 = transfer(A,B,10,"key")
r2 = transfer(A,B,10,"key")
assert r1 == r2
assert debits_count("key") == 1
| ID | Case | Setup | Expected |
|---|---|---|---|
| C1 | Many transfers same key | 10 threads same key | exactly one applied |
| C2 | Many keys, shared account | A=100, 10×amt=10 | final A=0, no negative |
| C3 | Deadlock avoidance | A→B and B→A simultaneously | both commit or safe retry |
Pseudocode:
def test_concurrent_same_key():
results = parallel(10, lambda: transfer(A,B,5,"k"))
assert sum(1 for r in results if r.ok) == 1
| ID | Case | Fault | Expected |
|---|---|---|---|
| D1 | Commit success | none | row in transfers, balances updated |
| D2 | Rollback on validation | bad amount | no row, no balance change |
| D3 | Crash before commit | kill conn mid-tx | no partial write |
| D4 | FK constraint | bad account | tx rolls back |
| D5 | Isolation | read during tx | uncommitted not visible |
Pseudocode:
def test_rollback():
with expect_rollback():
transfer(A,B,-1,"k") # should raise before commit
assert no_row("k")
def assert_invariant():
assert sum(balances()) == GLOBAL_TOTAL
This plan gives engineers concrete cases, pseudocode, and failure modes to implement against the SQL-backed service.
| chat | tencent/hy3 | |
|---|---|---|
| Score | 8.0 | 6.5 |
| Time | 17.9s | 1m26s |
| Tokens | 4224 | 1253 |
| Speed | 237 tok/s | 14 tok/s |
| Restarts | 0 | 0 |
Winner: Answer A (chat)
Judge labels: chat = Answer A; tencent/hy3 = Answer B
Judge JSON: winner=answer_a; answer_a_score=8.0; answer_b_score=6.5
Judge reasoning:
Answer A is the stronger refactor because it improves file handling, streaming, separation of parsing/validation, and testability while preserving key behavior more closely: lowercasing email, ok/bad counting, welcome email only on insert, continuing after bad rows, and especially the original specific print messages for invalid email and underage users. Its main behavioral deviation is that missing fields now print a custom 'Missing fields' error rather than the original IndexError text, but this is relatively minor. Answer B is readable and well-structured, but it changes observable output more substantially by replacing 'bad email ...', 'too young', and 'error ...' with different messages, and it changes the default file opening behavior by specifying UTF-8. It also claims validation failures are returned as values, which is inaccurate. Overall, Answer A better satisfies the requirement to preserve input/output behavior while improving maintainability.
You are given this legacy Python function. Refactor it to improve readability, testability, error handling, and maintainability without changing intended behavior. Preserve the input/output behavior as much as possible, but you may define small helper functions/classes. Explain the refactoring choices and provide the refactored code.
```python
def import_users(path, db, send_email):
f = open(path)
ok = 0
bad = 0
for line in f.readlines():
if line.strip() == '':
continue
parts = line.split(',')
try:
name = parts[0].strip()
email = parts[1].strip().lower()
age = int(parts[2])
if '@' not in email:
print('bad email ' + email)
bad += 1
continue
if age < 13:
print('too young')
bad += 1
continue
user = db.find_user(email)
if user:
user.name = name
user.age = age
db.save(user)
else:
user = {'name': name, 'email': email, 'age': age}
db.insert(user)
send_email(email, 'welcome')
ok += 1
except Exception as e:
print('error ' + str(e))
bad += 1
f.close()
return {'ok': ok, 'bad': bad}
```
class ParsingError(Exception):
"""Raised when a line cannot be parsed into the expected fields."""
pass
class EmailError(Exception):
"""Raised when the email is invalid."""
def __init__(self, email: str):
self.email = email
super().__init__(email)
class AgeError(Exception):
"""Raised when the age is below the minimum."""
pass
def _parse_line(line: str):
"""Parse a comma-separated line into name, email, age.
Raises:
ParsingError: if the line has fewer than 3 fields.
ValueError: if the age field cannot be converted to int.
"""
parts = line.split(',')
if len(parts) < 3:
raise ParsingError("Missing fields")
name = parts[0].strip()
email = parts[1].strip().lower()
age = int(parts[2]) # may raise ValueError
return name, email, age
def _validate_email(email: str):
"""Check that the email contains '@'.
Raises:
EmailError: if the email is invalid.
"""
if '@' not in email:
raise EmailError(email)
def _validate_age(age: int):
"""Check that the age is at least 13.
Raises:
AgeError: if the age is too low.
"""
if age < 13:
raise AgeError()
def import_users(path: str, db, send_email):
"""Import users from a CSV file and return counts of successful/failed rows.
Args:
path: path to the CSV file (columns: name, email, age).
db: database interface with methods find_user, save, insert.
send_email: callable(email, subject) to send welcome emails.
Returns:
dict with keys 'ok' (int) and 'bad' (int).
"""
ok = 0
bad = 0
with open(path) as f:
for line in f:
# Skip empty lines (preserves original behaviour: strip only for check)
if not line.strip():
continue
try:
name, email, age = _parse_line(line)
_validate_email(email)
_validate_age(age)
user = db.find_user(email)
if user:
user.name = name
user.age = age
db.save(user)
else:
user = {'name': name, 'email': email, 'age': age}
db.insert(user)
send_email(email, 'welcome')
ok += 1
except EmailError as e:
print('bad email ' + e.email)
bad += 1
except AgeError:
print('too young')
bad += 1
except Exception as e:
print('error ' + str(e))
bad += 1
return {'ok': ok, 'bad': bad}
with open(...) as f) ensures the file is always closed, even if an exception occurs. The original code could leak file descriptors.f instead of f.readlines() – this processes the file line by line without loading the entire file into memory._parse_line) and validation (_validate_email, _validate_age) are extracted into small, testable functions. Each has a single responsibility.ParsingError, EmailError, AgeError) make error handling explicit and preserve the exact error messages from the original:'bad email ' + email → EmailError'too young' → AgeError'error ' + str(e)try/except Exception that printed 'error ' + str(e) for everything, but also printed specific messages for email/age validation before the continue. The refactored version replicates this exactly by catching specific exceptions first and falling back to a general Exception catch.try block is kept tight around the operations that can fail, but still covers database and email calls (as in the original).ok, bad, name, email, age)._parse_line, _validate_email, and _validate_age can be unit tested in isolation without mocking files or databases.db and send_email objects.line.strip() only for the check, not modifying the original line).line.split(',') is kept (no pre‑strip of the line), so leading/trailing whitespace in the line is preserved in the split.1. Single Responsibility & Testability
- Split the monolithic function into small, pure-ish helpers:
- parse_user_line – parses/validates a single CSV line.
- upsert_user – handles DB insert/update and welcome email.
- import_users – orchestrates file reading and aggregates results.
- This makes each piece independently unit-testable.
2. Explicit Error Handling
- Replaced bare except Exception with specific validation errors.
- Introduced a custom UserImportError to distinguish domain errors from system errors.
- Validation failures are returned as values rather than silently printed.
3. Resource Safety
- Used with open(...) to guarantee the file is closed even on error.
4. Readability
- Clear function names, typed signatures (optional but helpful), and no magic prints.
- Validation rules are explicit and isolated.
5. Behavior Preservation
- Same return shape {'ok': n, 'bad': n}.
- Same side effects: DB update/insert and welcome email on new users.
- Same skipping of blank lines and same lowercasing of emails.
from typing import Dict, List, Tuple
class UserImportError(Exception):
"""Raised when a user record cannot be parsed or validated."""
def parse_user_line(line: str) -> Tuple[str, str, int]:
"""
Parse and validate a single CSV line.
Returns:
(name, email, age)
Raises:
UserImportError: if the line is malformed or fails validation.
"""
parts = line.split(",")
if len(parts) < 3:
raise UserImportError("expected 3 comma-separated fields")
name = parts[0].strip()
email = parts[1].strip().lower()
try:
age = int(parts[2])
except ValueError as exc:
raise UserImportError(f"invalid age: {parts[2]!r}") from exc
if "@" not in email:
raise UserImportError(f"bad email: {email}")
if age < 13:
raise UserImportError("user is too young")
return name, email, age
def upsert_user(db, send_email, name: str, email: str, age: int) -> None:
"""
Insert or update a user and send a welcome email if newly created.
"""
existing = db.find_user(email)
if existing:
existing.name = name
existing.age = age
db.save(existing)
else:
db.insert({"name": name, "email": email, "age": age})
send_email(email, "welcome")
def import_users(path: str, db, send_email) -> Dict[str, int]:
"""
Import users from a CSV file.
Returns:
Dict with counts of successfully imported and failed rows.
"""
ok = 0
bad = 0
with open(path, "r", encoding="utf-8") as f:
for line in f:
if line.strip() == "":
continue
try:
name, email, age = parse_user_line(line)
upsert_user(db, send_email, name, email, age)
ok += 1
except UserImportError as e:
# Domain-level validation failure
print(f"import error: {e}")
bad += 1
except Exception as e:
# Unexpected system-level error
print(f"unexpected error: {e}")
bad += 1
return {"ok": ok, "bad": bad}
bad but separated from domain errors for clarity.| chat | tencent/hy3 | |
|---|---|---|
| Score | 8.0 | 7.2 |
| Time | 29.3s | 1m28s |
| Tokens | 7138 | 1795 |
| Speed | 243 tok/s | 20 tok/s |
| Restarts | 0 | 0 |
Winner: Answer A (chat)
Judge labels: chat = Answer A; tencent/hy3 = Answer B
Judge JSON: winner=answer_a; answer_a_score=8.0; answer_b_score=7.2
Judge reasoning:
Answer A is more complete against the prompt: it defines policy/result models, algorithm-specific limiter classes, storage atomic operations, middleware and worker examples, fail-open/fail-closed behavior, Redis Lua atomicity, and per-tenant/per-route policy examples. It also keeps HTTP header generation outside the core. However, it has gaps: no clock abstraction, async API is referenced but not actually designed, token bucket metadata/capacity handling is underspecified, and the example instantiates only a FixedWindowRateLimiter despite having token-bucket policies. Answer B has a cleaner clock/testability story and good core models, but it collapses algorithm execution into the store interface more than ideal, lacks a real policy resolver, gives less detail on Redis key namespacing/TTL/atomicity, and has a flawed Flask middleware example. Overall Answer A better satisfies the architecture and completeness requirements.
Design an API and module architecture for a rate limiting library used by multiple services. Requirements: support fixed-window and token-bucket algorithms; support in-memory storage for local development and Redis-backed storage for production; allow per-tenant and per-route policies; work in synchronous HTTP middleware and asynchronous job workers; expose enough metadata for clients to set `X-RateLimit-*` headers; support fail-open/fail-closed configuration when the backing store is unavailable; be testable without Redis. Provide interfaces/classes, key methods, data models, error handling strategy, and an example of how a service would use it.
The library is designed as a set of composable modules that separate concerns: algorithms, storage backends, policy resolution, and middleware/integration. It supports fixed-window and token-bucket algorithms, in-memory and Redis storage, per-tenant and per-route policies, synchronous and asynchronous usage, fail-open/fail-closed modes, and is fully testable without Redis.
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class Algorithm(Enum):
FIXED_WINDOW = "fixed_window"
TOKEN_BUCKET = "token_bucket"
@dataclass
class RateLimitPolicy:
key: str # e.g. "tenant:route"
algorithm: Algorithm
limit: int # max requests per window (fixed) or bucket capacity (token)
window_seconds: int = 60 # for fixed window
refill_rate: float = 1.0 # tokens per second (token bucket)
burst: Optional[int] = None # optional burst capacity, defaults to limit
@dataclass
class RateLimitResult:
allowed: bool
limit: int
remaining: int
reset: int # Unix timestamp when window resets or bucket refills to capacity
retry_after: Optional[int] = None # seconds to wait if not allowed
The storage backend provides atomic operations needed by both algorithms. Implementations exist for InMemoryStorage (thread-safe, no external deps) and RedisStorage (uses Lua scripts for atomicity).
from abc import ABC, abstractmethod
from typing import Tuple
class StorageBackend(ABC):
@abstractmethod
def fixed_window_increment(self, key: str, limit: int, window_seconds: int) -> Tuple[bool, int, int]:
"""
Atomically increment a fixed-window counter.
Returns (allowed, current_count, ttl_remaining_seconds).
Raises StorageUnavailableError on failure.
"""
pass
@abstractmethod
def token_bucket_consume(self, key: str, capacity: int, refill_rate: float, now: float) -> Tuple[bool, int, float]:
"""
Atomically consume one token from a token bucket.
Returns (allowed, remaining_tokens, next_refill_timestamp).
Raises StorageUnavailableError on failure.
"""
pass
InMemoryStorage uses threading.Lock and a dict.
RedisStorage uses Lua scripts to guarantee atomicity.
Each algorithm implements a common interface and delegates atomic work to the storage backend.
from abc import ABC, abstractmethod
class RateLimiter(ABC):
def __init__(self, storage: StorageBackend, fail_open: bool = False):
self.storage = storage
self.fail_open = fail_open
@abstractmethod
def check(self, key: str, policy: RateLimitPolicy) -> RateLimitResult:
pass
class FixedWindowRateLimiter(RateLimiter):
def check(self, key: str, policy: RateLimitPolicy) -> RateLimitResult:
window_key = f"{key}:{int(time.time() / policy.window_seconds)}"
try:
allowed, count, ttl = self.storage.fixed_window_increment(
window_key, policy.limit, policy.window_seconds
)
except StorageUnavailableError:
if self.fail_open:
return RateLimitResult(allowed=True, limit=policy.limit,
remaining=policy.limit, reset=int(time.time())+policy.window_seconds)
raise
remaining = max(0, policy.limit - count)
reset = int(time.time()) + ttl
retry_after = ttl if not allowed else None
return RateLimitResult(allowed, policy.limit, remaining, reset, retry_after)
class TokenBucketRateLimiter(RateLimiter):
def check(self, key: str, policy: RateLimitPolicy) -> RateLimitResult:
bucket_key = f"bucket:{key}"
try:
allowed, remaining, next_refill = self.storage.token_bucket_consume(
bucket_key, policy.limit, policy.refill_rate, time.time()
)
except StorageUnavailableError:
if self.fail_open:
return RateLimitResult(allowed=True, limit=policy.limit,
remaining=policy.limit, reset=int(time.time())+1)
raise
reset = int(next_refill)
retry_after = max(0, reset - int(time.time())) if not allowed else None
return RateLimitResult(allowed, policy.limit, remaining, reset, retry_after)
fail_open=True, the rate limiter returns allowed=True with best‑effort metadata (e.g., full limit, current time as reset). The request proceeds.fail_open=False, the exception propagates to the caller (middleware/worker), which should return a 503 or retry later.class RateLimitMiddleware:
def __init__(self, get_response, rate_limiter: RateLimiter, policy_resolver):
self.get_response = get_response
self.rate_limiter = rate_limiter
self.policy_resolver = policy_resolver
def __call__(self, request):
key = self._build_key(request)
policy = self.policy_resolver(key)
if policy is None:
return self.get_response(request)
result = self.rate_limiter.check(key, policy)
if not result.allowed:
return HttpResponse(status=429, headers={
"X-RateLimit-Retry-After": str(result.retry_after)
})
response = self.get_response(request)
response["X-RateLimit-Limit"] = str(result.limit)
response["X-RateLimit-Remaining"] = str(result.remaining)
response["X-RateLimit-Reset"] = str(result.reset)
return response
def _build_key(self, request):
tenant = request.headers.get("X-Tenant-ID", "default")
route = request.path
return f"{tenant}:{route}"
async def rate_limited_task(task_func, key, policy, rate_limiter: RateLimiter):
result = await rate_limiter.check_async(key, policy) # async version of check
if not result.allowed:
raise RetryLater(result.retry_after)
return await task_func()
The library provides check_async wrappers that use asyncio.to_thread or native async storage methods.
InMemoryStorage in unit tests.def test_fixed_window_allows_within_limit():
storage = InMemoryStorage()
limiter = FixedWindowRateLimiter(storage, fail_open=False)
policy = RateLimitPolicy(key="test", algorithm=Algorithm.FIXED_WINDOW, limit=5, window_seconds=60)
for _ in range(5):
result = limiter.check("test", policy)
assert result.allowed
result = limiter.check("test", policy)
assert not result.allowed
from rate_limiter import (
RedisStorage, InMemoryStorage,
FixedWindowRateLimiter, TokenBucketRateLimiter,
RateLimitPolicy, Algorithm
)
import redis
# Production setup
redis_client = redis.Redis(host="redis", port=6379)
storage = RedisStorage(redis_client)
rate_limiter = FixedWindowRateLimiter(storage, fail_open=False)
# Development / tests
# storage = InMemoryStorage()
# rate_limiter = FixedWindowRateLimiter(storage, fail_open=True)
# Policy definitions (could come from config or database)
policies = {
"tenant1:/api/v1": RateLimitPolicy(
key="tenant1:/api/v1",
algorithm=Algorithm.FIXED_WINDOW,
limit=100,
window_seconds=60
),
"tenant2:/api/v2": RateLimitPolicy(
key="tenant2:/api/v2",
algorithm=Algorithm.TOKEN_BUCKET,
limit=10,
refill_rate=1.0
),
}
# Middleware integration (Flask example)
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.before_request
def rate_limit():
tenant = request.headers.get("X-Tenant-ID", "default")
route = request.path
key = f"{tenant}:{route}"
policy = policies.get(key)
if not policy:
return None # no rate limit for this key
result = rate_limiter.check(key, policy)
if not result.allowed:
return jsonify({"error": "rate limit exceeded"}), 429, {
"X-RateLimit-Retry-After": str(result.retry_after)
}
# Store result in request context for after_request to add headers
request.rate_limit_result = result
@app.after_request
def add_rate_limit_headers(response):
result = getattr(request, 'rate_limit_result', None)
if result:
response.headers["X-RateLimit-Limit"] = str(result.limit)
response.headers["X-RateLimit-Remaining"] = str(result.remaining)
response.headers["X-RateLimit-Reset"] = str(result.reset)
return response
rate_limiter/
├── __init__.py # exports public API
├── core.py # RateLimitPolicy, RateLimitResult, Algorithm, StorageUnavailableError
├── algorithms.py # FixedWindowRateLimiter, TokenBucketRateLimiter
├── storage/
│ ├── __init__.py # StorageBackend interface
│ ├── in_memory.py # InMemoryStorage
│ └── redis_storage.py # RedisStorage
├── middleware.py # HTTP middleware (sync) and async helpers
└── config.py # RateLimitConfig (fail_open, default policies, etc.)
fixed_window_increment and token_bucket_consume keep atomicity logic inside the storage, avoiding complex compare‑and‑swap loops in the algorithms.RateLimiter instance, not the storage, so the same storage can be used with different policies.X-RateLimit-*) is derived from RateLimitResult and set by the middleware, not by the core library.RedisStorage with InMemoryStorage and by mocking the storage interface.This architecture is language‑agnostic; the Python example can be adapted to any language with similar abstractions.
Below is a design for a small, dependency‑invertible rate‑limiting library called ratelib. It is intentionally framework‑agnostic and runtime‑agnostic.
from enum import Enum
class Algorithm(str, Enum):
FIXED_WINDOW = "fixed_window"
TOKEN_BUCKET = "token_bucket"
A policy describes limits for a given scope.
from dataclasses import dataclass, field
from typing import Optional
@dataclass(frozen=True)
class RateLimitPolicy:
algorithm: Algorithm
limit: int # max requests / tokens
window_seconds: int # fixed-window size or token-bucket refill period
burst: Optional[int] = None # token-bucket only; defaults to limit
scope_keys: tuple[str, ...] = ("tenant", "route")
Identifies what is being limited.
@dataclass
class LimitContext:
tenant_id: str
route: str
identifier: str # e.g. user_id or IP
cost: int = 1 # tokens/requests consumed
Returned to callers so they can emit X-RateLimit-* headers.
@dataclass
class LimitResult:
allowed: bool
algorithm: Algorithm
limit: int
remaining: int
reset_after_seconds: int # when window/tokens fully reset
retry_after_seconds: int # only meaningful if not allowed
policy_scope: str # e.g. "tenant:acme|route:/pay"
from abc import ABC, abstractmethod
class RateLimitStore(ABC):
@abstractmethod
def check_and_consume(
self,
policy: RateLimitPolicy,
ctx: LimitContext,
now: float
) -> LimitResult:
...
async def check_and_consume_async(
self,
policy: RateLimitPolicy,
ctx: LimitContext,
now: float
) -> LimitResult:
# default: run sync version in executor
return self.check_and_consume(policy, ctx, now)
MemoryStore(RateLimitStore) – dict-backed, monotonic clock, used in tests/dev.RedisStore(RateLimitStore) – Lua scripts for atomic fixed-window / token-bucket.check_and_consume_async uses redis.asyncio.Both share a pure helper module algorithms.py containing:
* fixed_window_check(state, policy, ctx, now)
* token_bucket_check(state, policy, ctx, now)
class StoreUnavailable(Exception):
pass
class RateLimiter:
def __init__(
self,
store: RateLimitStore,
fail_mode: str = "open", # "open" or "closed"
clock=None
):
self.store = store
self.fail_mode = fail_mode
self.clock = clock or time.monotonic
def evaluate(self, policy, ctx) -> LimitResult:
try:
return self.store.check_and_consume(policy, ctx, self.clock())
except StoreUnavailable:
if self.fail_mode == "closed":
# deny everything
return LimitResult(
allowed=False, algorithm=policy.algorithm,
limit=policy.limit, remaining=0,
reset_after_seconds=policy.window_seconds,
retry_after_seconds=policy.window_seconds,
policy_scope=self._scope(policy, ctx)
)
# fail-open
return LimitResult(
allowed=True, algorithm=policy.algorithm,
limit=policy.limit, remaining=policy.limit,
reset_after_seconds=0, retry_after_seconds=0,
policy_scope=self._scope(policy, ctx)
)
RedisStore raises StoreUnavailable on connection/timeouts; MemoryStore never does.
RateLimiter is sync-API but safe to call from async workers by awaiting the store:
async def evaluate_async(limiter, policy, ctx):
try:
return await limiter.store.check_and_consume_async(policy, ctx, time.monotonic())
except StoreUnavailable:
return limiter._fail_result(policy, ctx)
def headers_from_result(res: LimitResult) -> dict:
return {
"X-RateLimit-Limit": str(res.limit),
"X-RateLimit-Remaining": str(res.remaining),
"X-RateLimit-Reset": str(res.reset_after_seconds),
"Retry-After": str(res.retry_after_seconds) if not res.allowed else "0",
}
from ratelib import (
RateLimiter, MemoryStore, RedisStore,
RateLimitPolicy, Algorithm, LimitContext
)
policy = RateLimitPolicy(
algorithm=Algorithm.TOKEN_BUCKET,
limit=100, window_seconds=60, burst=20
)
# dev
limiter = RateLimiter(MemoryStore(), fail_mode="open")
# prod
# limiter = RateLimiter(RedisStore(url="redis://cache"), fail_mode="closed")
@app.before_request
def limit():
ctx = LimitContext(
tenant_id=request.headers.get("X-Tenant"),
route=request.path,
identifier=request.remote_addr
)
res = limiter.evaluate(policy, ctx)
for k,v in headers_from_result(res).items():
response.headers[k] = v
if not res.allowed:
abort(429)
async def process_job(tenant_id, job_id):
ctx = LimitContext(tenant_id=tenant_id, route="job:export", identifier=job_id)
res = await evaluate_async(limiter, policy, ctx)
if not res.allowed:
raise RateLimitExceeded(res.retry_after_seconds)
MemoryStore requires no I/O.RateLimiter(clock=lambda: 1000.0) gives deterministic time.RedisStore can be tested against fakeredis or redislite.ratelib/
__init__.py
models.py # dataclasses/enums
store.py # RateLimitStore ABC
memory.py # MemoryStore
redis_store.py # RedisStore
algorithms.py # pure fixed-window / token-bucket math
limiter.py # RateLimiter + fail-open/closed
http.py # header helpers
errors.py # StoreUnavailable, RateLimitExceeded
This design isolates algorithm math, storage backend, and failure policy; supports sync/async uniformly; and exposes exactly the metadata HTTP clients need.
| chat | tencent/hy3 | |
|---|---|---|
| Score | 9.0 | 8.5 |
| Time | 13.4s | 1m3s |
| Tokens | 3018 | 1184 |
| Speed | 225 tok/s | 19 tok/s |
| Restarts | 0 | 0 |
Winner: Answer A (chat)
Judge labels: chat = Answer A; tencent/hy3 = Answer B
Judge JSON: winner=answer_a; answer_a_score=9.0; answer_b_score=8.5
Judge reasoning:
Answer A clearly identifies the critical bottlenecks: full-file loading, retaining records, and unnecessary global sorting. It proposes the right streaming hash-aggregation design, bounded memory use, multiprocessing with per-worker dictionaries, final deterministic sorting only over aggregate keys, malformed-line handling, compression considerations, and concrete measurement steps. Its pseudocode is directly actionable. Answer B is also strong and covers most required areas, including correctness risks and measurement, but some suggestions are vaguer or slightly less precise, such as 'filter-then-parse-minimally' without a concrete safe method and optional asyncio/thread/process layering. Both omit explicit filter criteria in pseudocode, but Answer A is a bit more concrete and maintainable overall.
A Python service processes 200 GB/day of newline-delimited JSON logs. Current code loads each hourly file fully into memory, parses every line with `json.loads`, filters records by service and status, sorts all matching records by timestamp, then writes a daily report with counts per `(service, status_code, hour)`. It is slow and occasionally runs out of memory. Identify the main bottlenecks and propose an optimized design. Include algorithmic changes, memory usage strategy, I/O considerations, parallelism approach, correctness risks, and how you would measure whether the optimization worked. You may include pseudocode for the core aggregation loop.
(service, status_code, hour). Sorting can be replaced by per‑hour aggregation.json.loads on every line is CPU‑heavy, but unavoidable. However, using a faster JSON library (e.g., orjson) can help.(service, status_code, hour). This eliminates the sorting step entirely.(service, status_code, hour) combinations – usually a few thousand entries, not gigabytes.service is unexpectedly high (e.g., user IDs), the dictionary could grow large. In that case, use an external merge strategy: each worker writes intermediate counts to a temporary file, then a final merge‑sort combines them. For the typical log‑analysis scenario this is unnecessary.open(..., buffering=1<<20)) to reduce syscall overhead..gz), use gzip.open with streaming decompression.datetime.fromtimestamp(ts, tz=timezone.utc)) to avoid timezone shifts.json.loads in a try/except, log the error, and skip the line.None after .get() and skip incomplete records.memory_profiler to compare peak RSS before and after.import json
from collections import defaultdict
from datetime import datetime, timezone
from multiprocessing import Pool
def process_file(filepath):
"""Return a dict of counts for one hourly file."""
counts = defaultdict(int)
with open(filepath, 'r', buffering=1<<20) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
service = rec.get('service')
status = rec.get('status_code')
ts = rec.get('timestamp')
if None in (service, status, ts):
continue
# Assume ts is Unix seconds; convert to UTC hour
hour = datetime.fromtimestamp(ts, tz=timezone.utc).replace(
minute=0, second=0, microsecond=0
)
key = (service, status, hour)
counts[key] += 1
except (json.JSONDecodeError, KeyError, ValueError):
# Log error but continue
pass
return counts
def merge_counts(partials):
total = defaultdict(int)
for d in partials:
for k, v in d.items():
total[k] += v
return total
def main(file_list, num_workers=4):
with Pool(num_workers) as pool:
partials = pool.map(process_file, file_list)
final = merge_counts(partials)
# Write sorted report
with open('daily_report.csv', 'w') as out:
out.write('hour,service,status_code,count\n')
for key in sorted(final, key=lambda x: (x[2], x[0], x[1])):
hour_str = key[2].isoformat()
out.write(f'{hour_str},{key[0]},{key[1]},{final[key]}\n')
orjson.loads instead of json.loads for ~2× speedup.ijson) to avoid full parsing of every field.Full-file in-memory load
Reading an entire hourly file (can be several GB) into a string/list blows up RSS and causes OOM under memory pressure or larger-than-usual traffic.
Eager json.loads on every line
Parsing all fields for all records is wasteful when only a few fields (service, status_code, timestamp) are needed for filtering and aggregation.
Global sort by timestamp
Sorting all matching records across the whole day is O(n log n) and usually unnecessary: the report only needs counts per (service, status_code, hour), which is order-independent.
Single-threaded sequential processing
With 200 GB/day (~8–10 GB/hour), one core does both I/O and CPU-bound parsing, leaving most cores idle.
No streaming / early discard
Non-matching records are fully parsed and kept before being dropped.
(service, status_code, hour)open(path, "r", buffering=...) and iterate line by line.read() into memory.zstd/gzip) if storage-bound.multiprocessing or a job queue).asyncio + thread pool for I/O and process pool for parsing.service casing, status_code as int vs string.def process_file(path):
counts = defaultdict(int)
bad_lines = 0
with open(path, "r", encoding="utf-8") as f:
for line in f:
try:
# fast subset parse (e.g., json.loads or a scoped parser)
obj = json.loads(line)
svc = obj["service"]
code = int(obj["status_code"])
ts = obj["timestamp"]
hour = to_hour_bucket(ts)
except Exception:
bad_lines += 1
continue
counts[(svc, code, hour)] += 1
return counts, bad_lines
def reduce_counts(partials):
total = defaultdict(int)
for counts, _ in partials:
for k, v in counts.items():
total[k] += v
return total
/usr/bin/time -v or psutil (target < 1–2 GB/worker).This design removes the sort, eliminates full-file buffering, parses only what is needed, and scales linearly with hourly files and cores.
| chat | tencent/hy3 | |
|---|---|---|
| Score | 7.0 | 8.0 |
| Time | 19.8s | 1m16s |
| Tokens | 4944 | 2097 |
| Speed | 249 tok/s | 28 tok/s |
| Restarts | 0 | 0 |
Winner: Answer A (tencent/hy3)
Judge labels: chat = Answer B; tencent/hy3 = Answer A
Judge JSON: winner=answer_a; answer_a_score=8.0; answer_b_score=7.0
Judge reasoning:
Answer A is a cleaner end-to-end implementation with UTC timestamp normalization, valid-record-first deduplication, correct latest/tie-break behavior, dead-lettering, and consistent metrics. Its main weaknesses are accepting any two-letter country code such as XX despite the need to handle unknown countries, only reporting one rejection reason per row, and not explicitly sorting accepted output. Answer B has stronger multi-reason validation and deterministic sorting, and correctly rejects unknown country codes like XX, but it has more serious robustness issues: it assumes customer_id is a string and will crash on integer IDs, does not normalize timestamps, may fail or compare incorrectly with mixed naive/aware timestamps, and its timestamp parsing is less portable for Z ISO timestamps. Overall Answer A is more reliable for the core ETL behavior.
Design and implement, in a single file or clear pseudocode, an ETL transformation for daily customer records. Input is a list of dictionaries with fields: `customer_id`, `email`, `updated_at` ISO timestamp, `country`, and optional `marketing_opt_in`. Requirements: validate required fields; normalize emails to lowercase trimmed form; reject invalid emails; parse timestamps; deduplicate by `customer_id` keeping the record with the latest `updated_at`; if timestamps tie, prefer the record with non-null `marketing_opt_in`; map country names or ISO-2 codes to normalized ISO-2 uppercase for at least US, CA, GB, DE, FR; send rejected rows to a dead-letter output with reasons; produce metrics including total input, accepted, rejected, duplicates_removed, and per-reason counts. The output should be deterministic. Include representative tests or examples.
import re
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
# ----------------------------------------------------------------------
# Country mapping: common names and ISO-2 codes to normalized ISO-2 uppercase
_COUNTRY_MAP = {
# Names (case-insensitive matching)
"united states": "US",
"usa": "US",
"canada": "CA",
"great britain": "GB",
"united kingdom": "GB",
"uk": "GB",
"germany": "DE",
"france": "FR",
# ISO-2 codes (already uppercase, but we map for completeness)
"us": "US",
"ca": "CA",
"gb": "GB",
"de": "DE",
"fr": "FR",
}
# Set of valid ISO-2 codes (we accept any 2-letter uppercase as valid)
_VALID_ISO2 = set(_COUNTRY_MAP.values()) | {"AU", "IT", "ES", "JP", "CN", "BR", "IN", "RU", "MX", "NL", "SE", "NO", "DK", "FI", "PL", "CH", "AT", "BE", "PT", "IE", "NZ", "SG", "HK", "KR", "ZA", "AR", "CL", "CO", "PE", "VE", "EG", "NG", "KE", "MA", "TN", "DZ", "SA", "AE", "IL", "TR", "TH", "VN", "ID", "MY", "PH", "TW"} # extended set
_EMAIL_REGEX = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
# ----------------------------------------------------------------------
def normalize_email(email: str) -> Optional[str]:
"""Trim and lowercase email; return None if invalid format."""
if not email or not isinstance(email, str):
return None
normalized = email.strip().lower()
if _EMAIL_REGEX.match(normalized):
return normalized
return None
def parse_timestamp(ts_str: str) -> Optional[datetime]:
"""Parse ISO timestamp string; return None if invalid."""
if not ts_str or not isinstance(ts_str, str):
return None
try:
# Python 3.7+ fromisoformat handles most ISO formats
return datetime.fromisoformat(ts_str)
except (ValueError, TypeError):
return None
def normalize_country(country: str) -> Optional[str]:
"""Map country name or code to ISO-2 uppercase; return None if unrecognized."""
if not country or not isinstance(country, str):
return None
cleaned = country.strip().lower()
# Direct lookup
if cleaned in _COUNTRY_MAP:
return _COUNTRY_MAP[cleaned]
# If it's already a 2-letter uppercase code, accept it
if len(cleaned) == 2 and cleaned.isalpha():
upper = cleaned.upper()
if upper in _VALID_ISO2:
return upper
# Otherwise reject
return None
def validate_record(record: Dict[str, Any]) -> List[str]:
"""Return list of validation error reasons. Empty list means valid."""
reasons = []
# Required fields
for field in ('customer_id', 'email', 'updated_at', 'country'):
if field not in record or record[field] is None or (isinstance(record[field], str) and record[field].strip() == ''):
reasons.append(f"missing required field: {field}")
if reasons:
return reasons # no need to check further if fields missing
# Email
email = record['email']
if not normalize_email(email):
reasons.append("invalid email format")
# Timestamp
ts = record['updated_at']
if not parse_timestamp(ts):
reasons.append("invalid updated_at timestamp")
# Country
country = record['country']
if not normalize_country(country):
reasons.append("invalid or unrecognized country")
return reasons
# ----------------------------------------------------------------------
def transform_daily_records(input_records: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
"""
ETL transformation for daily customer records.
Returns:
accepted: list of normalized, deduplicated records (sorted by customer_id)
dead_letter: list of rejected records with original data and reasons
metrics: dict with total_input, accepted, rejected, duplicates_removed, per_reason
"""
total_input = len(input_records)
dead_letter: List[Dict[str, Any]] = []
per_reason: Dict[str, int] = {}
# Temporary storage for valid records before dedup
# key: customer_id, value: list of (updated_at_parsed, marketing_non_null, index, normalized_record)
valid_by_customer: Dict[str, List[Tuple[datetime, bool, int, Dict[str, Any]]]] = {}
valid_count = 0
for idx, record in enumerate(input_records):
reasons = validate_record(record)
if reasons:
# Rejected
dead_letter.append({
"original_record": record,
"reasons": reasons
})
for r in reasons:
per_reason[r] = per_reason.get(r, 0) + 1
continue
# Valid record – normalize fields
customer_id = record['customer_id'].strip() # ensure string
email = normalize_email(record['email']) # already validated
updated_at_str = record['updated_at'] # keep original string
updated_at_dt = parse_timestamp(updated_at_str) # already validated
country = normalize_country(record['country']) # already validated
marketing_opt_in = record.get('marketing_opt_in') # may be None
normalized = {
"customer_id": customer_id,
"email": email,
"updated_at": updated_at_str, # original valid ISO string
"country": country,
"marketing_opt_in": marketing_opt_in
}
# Store for dedup
marketing_non_null = marketing_opt_in is not None
valid_by_customer.setdefault(customer_id, []).append(
(updated_at_dt, marketing_non_null, idx, normalized)
)
valid_count += 1
# Deduplicate: for each customer_id, keep best record
accepted = []
duplicates_removed = 0
for customer_id, records in valid_by_customer.items():
# Sort by: updated_at descending, marketing_non_null descending (True first), index descending (later preferred)
records.sort(key=lambda x: (x[0], x[1], x[2]), reverse=True)
best = records[0][3] # normalized record
accepted.append(best)
duplicates_removed += len(records) - 1
# Sort accepted by customer_id for deterministic output
accepted.sort(key=lambda r: r['customer_id'])
# Metrics
metrics = {
"total_input": total_input,
"accepted": len(accepted),
"rejected": len(dead_letter),
"duplicates_removed": duplicates_removed,
"per_reason": per_reason
}
return accepted, dead_letter, metrics
# ----------------------------------------------------------------------
# Representative tests / examples
if __name__ == "__main__":
# Example 1: basic valid records
input1 = [
{"customer_id": "1", "email": " Alice@Example.COM ", "updated_at": "2024-01-01T12:00:00", "country": "US", "marketing_opt_in": True},
{"customer_id": "2", "email": "bob@test.org", "updated_at": "2024-01-01T13:00:00", "country": "Canada", "marketing_opt_in": None},
{"customer_id": "3", "email": "charlie@bad", "updated_at": "2024-01-01T14:00:00", "country": "GB", "marketing_opt_in": False},
{"customer_id": "1", "email": "alice@example.com", "updated_at": "2024-01-02T12:00:00", "country": "us", "marketing_opt_in": None}, # duplicate, newer
{"customer_id": "2", "email": "bob@test.org", "updated_at": "2024-01-01T13:00:00", "country": "CA", "marketing_opt_in": True}, # tie timestamp, prefer non-null marketing
{"customer_id": "4", "email": "dave@domain.com", "updated_at": "invalid-date", "country": "DE", "marketing_opt_in": None}, # invalid timestamp
{"customer_id": "5", "email": "eve@domain.com", "updated_at": "2024-01-01T15:00:00", "country": "France", "marketing_opt_in": None},
{"customer_id": "6", "email": "frank@domain.com", "updated_at": "2024-01-01T16:00:00", "country": "XX", "marketing_opt_in": None}, # invalid country
]
accepted, dead, metrics = transform_daily_records(input1)
print("=== Example 1 ===")
print("Accepted:")
for rec in accepted:
print(rec)
print("\nDead letter:")
for rec in dead:
print(rec)
print("\nMetrics:", metrics)
print()
# Example 2: empty input
accepted2, dead2, metrics2 = transform_daily_records([])
print("=== Example 2: empty input ===")
print("Accepted:", accepted2)
print("Dead:", dead2)
print("Metrics:", metrics2)
print()
# Example 3: all rejected
input3 = [
{"customer_id": "", "email": "x", "updated_at": "bad", "country": ""},
{"customer_id": "a", "email": "a@b", "updated_at": "2024-01-01", "country": "US"} # email invalid (no dot)
]
accepted3, dead3, metrics3 = transform_daily_records(input3)
print("=== Example 3: all rejected ===")
print("Accepted:", accepted3)
print("Dead:", dead3)
print("Metrics:", metrics3)
print()
# Example 4: tie-breaking with same timestamp and both null marketing
input4 = [
{"customer_id": "1", "email": "a@b.com", "updated_at": "2024-01-01T00:00:00", "country": "US", "marketing_opt_in": None},
{"customer_id": "1", "email": "a@b.com", "updated_at": "2024-01-01T00:00:00", "country": "US", "marketing_opt_in": None},
]
accepted4, dead4, metrics4 = transform_daily_records(input4)
print("=== Example 4: tie (both null) ===")
print("Accepted (should be 1 record, the later one):", accepted4)
print("Metrics:", metrics4)
print()
# Example 5: country mapping
input5 = [
{"customer_id": "1", "email": "a@b.com", "updated_at": "2024-01-01T00:00:00", "country": "united kingdom", "marketing_opt_in": None},
{"customer_id": "2", "email": "b@c.com", "updated_at": "2024-01-01T00:00:00", "country": "DE", "marketing_opt_in": None},
{"customer_id": "3", "email": "c@d.com", "updated_at": "2024-01-01T00:00:00", "country": "fr", "marketing_opt_in": None},
]
accepted5, _, _ = transform_daily_records(input5)
print("=== Example 5: country normalization ===")
for rec in accepted5:
print(f"{rec['customer_id']}: {rec['country']}")
# Expected: 1: GB, 2: DE, 3: FR
Below is a single-file, dependency-free Python implementation (Python 3.8+) that satisfies all stated requirements. It is deterministic, explicitly validated, and includes representative tests.
"""
Daily customer records ETL transformation.
Features:
- Required field validation
- Email normalization + validation
- Timestamp parsing (ISO 8601)
- Deterministic deduplication:
* by customer_id
* keep latest updated_at
* tie-break: prefer non-null marketing_opt_in
- Country normalization to ISO-2 uppercase (US, CA, GB, DE, FR + passthrough)
- Dead-letter output with reasons
- Metrics collection
"""
from datetime import datetime, timezone
from email.utils import parseaddr
from typing import Any, Dict, List, Tuple
# -----------------------------
# Configuration / Reference data
# -----------------------------
REQUIRED_FIELDS = ["customer_id", "email", "updated_at", "country"]
COUNTRY_ALIASES = {
"US": "US", "USA": "US", "UNITED STATES": "US", "UNITED STATES OF AMERICA": "US",
"CA": "CA", "CAN": "CA", "CANADA": "CA",
"GB": "GB", "UK": "GB", "UNITED KINGDOM": "GB", "GREAT BRITAIN": "GB",
"DE": "DE", "GER": "DE", "GERMANY": "DE",
"FR": "FR", "FRA": "FR", "FRANCE": "FR",
}
# -----------------------------
# Helpers
# -----------------------------
def normalize_email(raw: Any) -> Tuple[str, bool]:
"""Return (normalized_email, is_valid)."""
if not isinstance(raw, str):
return "", False
cleaned = raw.strip().lower()
# parseaddr handles surrounding names; we only keep address part
_, addr = parseaddr(cleaned)
addr = addr.strip().lower()
if "@" not in addr or "." not in addr.split("@")[-1]:
return addr, False
# basic local/@/domain structure check
parts = addr.split("@")
if len(parts) != 2 or not parts[0] or not parts[1]:
return addr, False
return addr, True
def parse_timestamp(raw: Any) -> Tuple[datetime, bool]:
"""Parse ISO timestamp; always return tz-aware UTC datetime or (None, False)."""
if not isinstance(raw, str):
return None, False
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc), True
except ValueError:
return None, False
def normalize_country(raw: Any) -> Tuple[str, bool]:
if not isinstance(raw, str):
return "", False
key = raw.strip().upper()
if key in COUNTRY_ALIASES:
return COUNTRY_ALIASES[key], True
# already ISO-2 uppercase
if len(key) == 2 and key.isalpha():
return key, True
return raw, False
# -----------------------------
# Core ETL
# -----------------------------
def etl_transform(records: List[Dict[str, Any]]) -> Dict[str, Any]:
metrics = {
"total_input": len(records),
"accepted": 0,
"rejected": 0,
"duplicates_removed": 0,
"reasons": {},
}
dead_letter: List[Dict[str, Any]] = []
accepted_map: Dict[Any, Dict[str, Any]] = {}
def reject(rec, reason):
metrics["rejected"] += 1
metrics["reasons"][reason] = metrics["reasons"].get(reason, 0) + 1
dead_letter.append({"record": rec, "reason": reason})
for rec in records:
# 1. Required fields
missing = [f for f in REQUIRED_FIELDS if f not in rec or rec[f] is None]
if missing:
reject(rec, f"missing_required:{','.join(missing)}")
continue
# 2. Email
email, ok = normalize_email(rec["email"])
if not ok:
reject(rec, "invalid_email")
continue
# 3. Timestamp
ts, ok = parse_timestamp(rec["updated_at"])
if not ok:
reject(rec, "invalid_timestamp")
continue
# 4. Country
country, ok = normalize_country(rec["country"])
if not ok:
reject(rec, "invalid_country")
continue
# 5. Deduplication
cid = rec["customer_id"]
opt_in = rec.get("marketing_opt_in", None)
candidate = {
"customer_id": cid,
"email": email,
"updated_at": ts.isoformat(),
"country": country,
"marketing_opt_in": opt_in,
}
if cid in accepted_map:
metrics["duplicates_removed"] += 1
existing = accepted_map[cid]
ext_ts = datetime.fromisoformat(existing["updated_at"])
# Keep latest; tie-break non-null opt-in
if (ts > ext_ts or
(ts == ext_ts and existing["marketing_opt_in"] is None and opt_in is not None)):
accepted_map[cid] = candidate
else:
accepted_map[cid] = candidate
accepted = list(accepted_map.values())
metrics["accepted"] = len(accepted)
return {
"accepted": accepted,
"dead_letter": dead_letter,
"metrics": metrics,
}
# -----------------------------
# Tests / Examples
# -----------------------------
if __name__ == "__main__":
sample = [
{"customer_id": 1, "email": "Alice@Example.com ", "updated_at": "2023-01-01T10:00:00Z",
"country": "USA", "marketing_opt_in": True},
{"customer_id": 1, "email": "alice@example.com", "updated_at": "2023-01-02T10:00:00Z",
"country": "US", "marketing_opt_in": None},
{"customer_id": 2, "email": "bob@example.com", "updated_at": "2023-01-01T09:00:00Z",
"country": "Canada", "marketing_opt_in": False},
{"customer_id": 3, "email": "bad-email", "updated_at": "2023-01-01T09:00:00Z",
"country": "GB"},
{"customer_id": 4, "email": "carol@example.com", "updated_at": "not-a-date",
"country": "DE"},
{"customer_id": 5, "email": "dan@example.com", "updated_at": "2023-01-01T08:00:00Z"},
{"customer_id": 6, "email": "eve@example.com", "updated_at": "2023-01-01T08:00:00Z",
"country": "FR", "marketing_opt_in": None},
{"customer_id": 6, "email": "eve@example.com", "updated_at": "2023-01-01T08:00:00Z",
"country": "FR", "marketing_opt_in": True},
]
result = etl_transform(sample)
import json
print(json.dumps(result, indent=2, default=str))
customer_id=1: duplicate removed, latest timestamp kept (opt-in None).customer_id=3: rejected (invalid_email).customer_id=4: rejected (invalid_timestamp).customer_id=5: rejected (missing_required:country).customer_id=6: duplicate removed, non-null opt-in kept.| chat | tencent/hy3 | |
|---|---|---|
| Score | 7.5 | 8.0 |
| Time | 7.4s | 47.9s |
| Tokens | 1851 | 1276 |
| Speed | 249 tok/s | 27 tok/s |
| Restarts | 0 | 0 |
Winner: Answer A (tencent/hy3)
Judge labels: chat = Answer B; tencent/hy3 = Answer A
Judge JSON: winner=answer_a; answer_a_score=8.0; answer_b_score=7.5
Judge reasoning:
Both answers correctly identify that read(n) can return fewer than n bytes for both header and payload, enforce a maximum size, await process, handle disconnects, and close with wait_closed in a finally block. Answer B is more idiomatic by using reader.readexactly(), but it suppresses asyncio.CancelledError instead of re-raising it, which is usually incorrect for cooperative cancellation. Answer A handles cancellation better by re-raising and has somewhat more robust cleanup/logging, though its custom read_exactly is less idiomatic than readexactly and it does not clearly distinguish clean EOF from a partial header protocol error. Overall Answer A is slightly stronger.
The following asyncio TCP server receives length-prefixed messages. The protocol is a 4-byte big-endian unsigned length followed by exactly that many payload bytes. Messages may arrive fragmented or coalesced. Find the bug(s), explain how they manifest, and provide corrected code for handling a connection robustly. Include limits and error handling.
```python
import asyncio
import struct
async def handle(reader, writer):
while True:
header = await reader.read(4)
if not header:
break
length = struct.unpack('>I', header)[0]
data = await reader.read(length)
if len(data) != length:
print('short read')
break
await process(data)
writer.close()
```
Assume `process(data)` is an async function. Address partial headers, oversized lengths, client disconnects, cancellation, and ensuring the writer is closed cleanly.
Partial header reads
reader.read(4) may return 1–3 bytes if the connection is closed or data arrives in small chunks. The code only checks for an empty bytes object (if not header), so a partial header will cause struct.unpack to fail with struct.error: unpack requires a buffer of 4 bytes. This crashes the handler and leaves the writer unclosed.
Partial payload reads
reader.read(length) may return fewer than length bytes. The code checks len(data) != length and breaks, but this discards the partial data and closes the connection. A robust implementation should read in a loop until the full payload is received, or handle the premature end-of-stream gracefully.
No message size limit
An attacker (or a buggy peer) can send a 4-byte header with a huge length (e.g., 4 GiB). The server will attempt to allocate a buffer of that size, likely causing MemoryError or hanging indefinitely. A maximum message size must be enforced.
Unclean writer closure
writer.close() is called without await writer.wait_closed(). This does not wait for the underlying transport to finish flushing data, and may lead to data loss or resource leaks. Also, if an exception occurs (e.g., CancelledError, struct.error), the writer.close() line is never reached.
Cancellation not handled
If the handler task is cancelled (e.g., server shutdown), the while True loop is interrupted, and the writer is not closed. The handler should catch asyncio.CancelledError to clean up.
Exception from process(data)
If process raises an exception, the loop breaks and the writer is closed, but the exception propagates to the caller (the server). This may be acceptable, but often you want to log the error and close the connection gracefully without crashing the server.
Missing try/finally
The writer should be closed in a finally block to guarantee cleanup even on errors or cancellation.
import asyncio
import struct
MAX_MESSAGE_SIZE = 10 * 1024 * 1024 # 10 MB limit
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
try:
while True:
# Read exactly 4 bytes for the header
try:
header = await reader.readexactly(4)
except asyncio.IncompleteReadError:
# Connection closed before receiving a full header
break
length = struct.unpack('>I', header)[0]
# Enforce maximum message size
if length > MAX_MESSAGE_SIZE:
print(f'Message too large: {length} bytes (max {MAX_MESSAGE_SIZE})')
break
# Read exactly `length` bytes for the payload
try:
data = await reader.readexactly(length)
except asyncio.IncompleteReadError:
# Connection closed mid-payload
print('Short read: connection closed before full message received')
break
try:
await process(data)
except Exception as e:
# Log error and close connection
print(f'Error processing message: {e}')
break
except asyncio.CancelledError:
# Task cancelled (e.g., server shutdown) – clean up
pass
finally:
# Ensure the writer is closed and flushed
writer.close()
await writer.wait_closed()
reader.readexactly(4) – Guarantees exactly 4 bytes are read, raising IncompleteReadError if the connection closes early. This replaces the manual partial-header check.reader.readexactly(length) – Reads the exact payload length, raising IncompleteReadError on premature disconnection. This avoids the short-read break and ensures we always get the full message.MAX_MESSAGE_SIZE – Rejects messages larger than 10 MB (adjustable). Prevents memory exhaustion.try/except around process – Catches any exception from the application logic, logs it, and closes the connection gracefully.try/finally – Guarantees writer.close() and await writer.wait_closed() are called even if an exception or cancellation occurs.asyncio.CancelledError – Caught to allow clean shutdown without propagating the cancellation further (the server typically handles task cancellation). The finally block still runs.This version handles fragmented/coalesced messages, oversized lengths, client disconnects, cancellation, and ensures the writer is closed cleanly.
Partial header reads are not handled
reader.read(4) returns up to 4 bytes. If the client sends the 4-byte length prefix fragmented (e.g. 2 bytes, then 2 bytes), header will be shorter than 4 bytes. struct.unpack('>I', header) then raises struct.error, crashing the handler instead of continuing to read the rest of the header.
Oversized lengths are not limited
length comes directly from the client. A malicious or buggy client can send 0xFFFFFFFF, causing await reader.read(length) to try to buffer ~4 GB, likely exhausting memory or hanging the connection.
Short payload reads are detected but not cleanly handled
The code breaks on short reads, which is fine, but it does not distinguish “client closed connection” from “protocol violation”. Also, after break the writer is closed, but not drained/waited for, and exceptions may skip writer.close() if they occur earlier.
Client disconnect during header/payload read is ambiguous
if not header: only catches a clean EOF before any bytes. A disconnect after a partial header (e.g. 2 bytes then EOF) raises struct.error instead of being treated as a disconnect.
Cancellation and cleanup are not robust
If the task is cancelled (e.g. server shutdown), writer.close() may never run, leaving the socket open. There is no try/finally or asyncio.CancelledError handling.
Writer is never drained
If process or later writes use the writer, await writer.drain() is required to avoid unbounded buffering. Even if only close() is used, close() does not wait for pending data to flush.
import asyncio
import struct
import logging
logger = logging.getLogger(__name__)
# Protocol limits
MAX_MESSAGE_SIZE = 10 * 1024 * 1024 # 10 MiB max payload
HEADER_FMT = '>I'
HEADER_SIZE = struct.calcsize(HEADER_FMT)
async def read_exactly(reader: asyncio.StreamReader, n: int) -> bytes | None:
"""
Read exactly n bytes.
Returns the bytes, or None if EOF is reached before n bytes.
"""
buf = b''
while len(buf) < n:
chunk = await reader.read(n - len(buf))
if not chunk:
return None # EOF
buf += chunk
return buf
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info('peername')
try:
while True:
header = await read_exactly(reader, HEADER_SIZE)
if header is None:
# Clean disconnect or EOF between messages
break
try:
length = struct.unpack(HEADER_FMT, header)[0]
except struct.error:
logger.warning("Bad header from %s", peer)
break
if length > MAX_MESSAGE_SIZE:
logger.warning("Oversized message (%d bytes) from %s", length, peer)
break
data = await read_exactly(reader, length)
if data is None:
# Client disconnected mid-payload
logger.info("Client %s disconnected mid-payload", peer)
break
try:
await process(data)
except Exception:
logger.exception("Error processing message from %s", peer)
# Depending on policy, you may break or continue
break
except asyncio.CancelledError:
logger.info("Connection handler for %s cancelled", peer)
raise # allow proper cancellation
except (ConnectionResetError, BrokenPipeError) as e:
logger.info("Connection lost with %s: %s", peer, e)
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
read_exactly loops until the full header or payload is received.None on EOF so the loop exits cleanly instead of throwing.MAX_MESSAGE_SIZE before allocating reads.try/finally ensures the writer is always closed and waited on.writer.wait_closed() flushes and releases the socket.This handles fragmentation, coalescing, malicious input, and lifecycle correctness.