fix: prevent unsafe write retries and bound Blob uploads (#4977)

Ambiguous gateway failures can occur after a write commits, so replaying
the operation can append duplicate rows. Non-idempotent writes now retry
only known precommit failures. Multipart retries reuse the same upload
ID, part ID, and encoded bytes; ambiguous completion returns an
unknown-outcome error without abort or replay. A definite 409/429
completion rejection triggers best-effort cleanup using the call's retry
budget.

Replayable parts and the shared byte-based partition target default to
64 MiB. Dictionary-bearing batches are compacted before size estimation
and again when actual compressed IPC framing requires further splitting:
keys are remapped without changing their types or schema, view backing
buffers are collected, and ListView/LargeListView child ranges are
compacted as an interval union so overlapping views retain sharing.
Parts are prepared on demand; a single row and its schema can still
exceed the target. Multipart progress starts at zero and counts each
acknowledged IPC body once. Rust write builders and Python/TypeScript
`add` expose per-call retry overrides. Atomic `create_table` pre-encodes
to a temporary file and rejects compressed IPC over 256 MiB before
sending any request; the schema-first import guidance now includes
Python, TypeScript, and Rust examples. The shared partition target also
affects local automatic partitioning.

At `cb2a79473bd1b51b950c9fa3e9f247f929c8ac3b`, 70 targeted Rust cases
pass on source verified against the committed four-file patch. With a 16
KiB target and 128 unique 1 KiB dictionary values, the original ordinary
dictionary reproducer drops from 128 requests / 16,942,080 bytes to 16 /
143,488; ListView and LargeListView drop from 128 / 17,056,768 to 16 /
149,632. Encoded HTTP bodies are decoded to check values, nulls, slices,
metadata, changing dictionaries, and overlapping views; additional cases
cover framing-driven splitting, oversized rows, stable retry bytes/IDs,
acknowledged progress, and atomic create. Real HTTP fault tests commit
Blob payloads to a local Lance table and verify byte equality, no
duplicate append, and zero abort/replay after ambiguous completion.

The earlier 811 remote and 7 progress cases at
`f36d217110a25f6b501c0255df9aaa60e3c8000a`, Linux Python remote
database/options results (437 cases plus 8 fixture consumers), Node
retry-binding evidence, and Rust/TypeScript documentation example checks
remain historical validation. Python/Node extensions were not rebuilt
for the dictionary follow-up; these results do not establish current
binding integration or fresh cross-platform/minimum-dependency CI
acceptance.

A separate real Query Node lifecycle experiment used SDK
`b8efe7d2d63647d4962789ca5fc042b59efe073d` and Sophon
`58b7895c7c287810fff4e89f13226fc5fa60d9ef`. After the actual listener
forced a connection closed, a surviving QN accepted a retry with
identical upload ID, part ID, and IPC bytes. Ordinary insert and
complete response-loss scenarios sent no replay or abort; fresh reads
observed exactly one commit and exact Blob bytes in all three scenarios.
This local experiment used a 1-second drain grace, two 32 KiB payloads,
and one distinct staged part. It validates acknowledgement-loss recovery
at those revisions, not concurrent overlapping attempts, large uploads,
physical orphan cleanup, the later dictionary encoding change, or the
original sixth-insert EOF.

The part target is not a process memory limit: source/ancestor buffers
and temporary compaction allocations may coexist. Struct/FixedSizeList
parent nulls can retain hidden child keys; this is not complete
logical-reachability GC for every Arrow combination, and retained hidden
values may affect wire size. The current local Lance 15.0.0-beta.17
rejects ListView/LargeListView persistence; the tests establish remote
client IPC encoding behavior, not native or Cloud persistence support.
Live Cloud, full workspace/Clippy, the original ordinary-insert
disconnect root cause, and Sophon's asynchronous transaction cleanup
behavior are not established. Cancelling during staging retains the
existing server-session cleanup limitation. Function reader delivery is
separate.
This commit is contained in:
Xuanwo
2026-11-13 02:05:13 +08:00
committed by GitHub
parent 10f89b3c32
commit 725eee8a47
22 changed files with 2484 additions and 390 deletions
+1
View File
@@ -544,6 +544,7 @@ class Table:
progress: Optional[Any] = None,
write_parallelism: Optional[int] = None,
allow_external_blob_outside_bases: bool = False,
retries: Optional[int] = None,
) -> AddResult: ...
async def update(
self, updates: Dict[str, str], where: Optional[str]
+5
View File
@@ -719,6 +719,7 @@ class RemoteTable(Table):
progress: Optional[Union[bool, Callable, Any]] = None,
write_parallelism: Optional[int] = None,
allow_external_blob_outside_bases: bool = False,
retries: Optional[int] = None,
) -> AddResult:
"""Add more data to the [Table][lancedb.table.Table].
@@ -753,6 +754,9 @@ class RemoteTable(Table):
using too much memory.
allow_external_blob_outside_bases: bool, default False
Not supported in LanceDB Enterprise. Setting this raises.
retries: int, optional
Override the retry budget for this call (0 to 255). Zero disables
retries, including multipart parts. Ambiguous commits are never replayed.
Returns
-------
@@ -770,6 +774,7 @@ class RemoteTable(Table):
progress=progress,
write_parallelism=write_parallelism,
allow_external_blob_outside_bases=allow_external_blob_outside_bases,
retries=retries,
)
)
finally:
+17
View File
@@ -2256,6 +2256,7 @@ class Table(ABC):
progress: Optional[Union[bool, Callable, Any]] = None,
write_parallelism: Optional[int] = None,
allow_external_blob_outside_bases: bool = False,
retries: Optional[int] = None,
) -> AddResult:
"""Add more data to the [Table][lancedb.table.Table].
@@ -2316,6 +2317,10 @@ class Table(ABC):
Store blob URIs that sit outside registered blob bases. The row
keeps a reference, so the object has to stay readable. Local
tables only.
retries: int, optional
Override the remote retry budget for this call (0 to 255). Zero
disables retries, including multipart parts. Ambiguous commits are
never replayed. Ignored by LanceDB Embedded.
Returns
-------
@@ -4821,6 +4826,7 @@ class LanceTable(Table):
progress: Optional[Union[bool, Callable, Any]] = None,
write_parallelism: Optional[int] = None,
allow_external_blob_outside_bases: bool = False,
retries: Optional[int] = None,
) -> AddResult:
"""Add data to the table.
If vector columns are missing and the table
@@ -4852,6 +4858,10 @@ class LanceTable(Table):
allow_external_blob_outside_bases: bool, default False
Allow blob URIs outside registered bases. See :meth:`Table.add`.
LanceDB Embedded only.
retries: int, optional
Override the remote retry budget for this call (0 to 255). Zero
disables retries, including multipart parts. Ambiguous commits are
never replayed. Ignored by LanceDB Embedded.
Returns
-------
@@ -4869,6 +4879,7 @@ class LanceTable(Table):
progress=progress,
write_parallelism=write_parallelism,
allow_external_blob_outside_bases=allow_external_blob_outside_bases,
retries=retries,
)
)
finally:
@@ -7080,6 +7091,7 @@ class AsyncTable:
progress: Optional[Union[bool, Callable, Any]] = None,
write_parallelism: Optional[int] = None,
allow_external_blob_outside_bases: bool = False,
retries: Optional[int] = None,
) -> AddResult:
"""Add more data to the [AsyncTable][lancedb.table.AsyncTable].
@@ -7118,6 +7130,10 @@ class AsyncTable:
allow_external_blob_outside_bases: bool, default False
Allow blob URIs outside registered bases. See :meth:`Table.add`.
LanceDB Embedded only.
retries: int, optional
Override the remote retry budget for this call (0 to 255). Zero
disables retries, including multipart parts. Ambiguous commits are
never replayed. Ignored by LanceDB Embedded.
"""
schema = await self.schema()
@@ -7172,6 +7188,7 @@ class AsyncTable:
progress=progress,
write_parallelism=write_parallelism,
allow_external_blob_outside_bases=allow_external_blob_outside_bases,
retries=retries,
)
except RuntimeError as e:
if "Cast error" in str(e):
+272 -30
View File
@@ -59,20 +59,124 @@ import pyarrow as pa
from utils import start_server_thread
def make_mock_http_handler(handler):
def make_mock_http_handler(handler, *, buffer_body=True):
class MockLanceDBHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
handler(self)
def do_POST(self):
handler(self)
if not buffer_body:
handler(self)
return
def do_PUT(self):
handler(self)
# HTTP/1.0 closes after the response. Leaving a streamed upload unread
# can reset the connection while the client is still writing its body.
# Keep the original framing so handlers can inspect the actual request.
source = self.rfile
body = bytearray()
if self.headers.get("Transfer-Encoding") == "chunked":
while True:
line = source.readline()
assert line.endswith(b"\r\n")
body.extend(line)
size = int(line.split(b";")[0], 16)
if size == 0:
while True:
line = source.readline()
assert line.endswith(b"\r\n")
body.extend(line)
if line == b"\r\n":
break
break
chunk = source.read(size + 2)
assert len(chunk) == size + 2 and chunk[-2:] == b"\r\n"
body.extend(chunk)
else:
size = int(self.headers.get("Content-Length", 0))
body.extend(source.read(size))
assert len(body) == size
self.rfile = io.BytesIO(body)
try:
handler(self)
finally:
self.rfile.close()
self.rfile = source
do_PUT = do_POST
return MockLanceDBHandler
@pytest.mark.parametrize("chunked", [False, True])
@pytest.mark.parametrize("read_body", [False, True])
def test_mock_http_handler_waits_for_body(chunked, read_body):
body = (
b"7;ext=value\r\npayload\r\n0\r\nX-Trailer: value\r\n\r\n"
if chunked
else b"payload"
)
received = []
def handler(request):
if read_body:
assert (request.headers.get("Transfer-Encoding") == "chunked") == chunked
received.append(request.rfile.read(len(body)))
request.send_response(200)
request.end_headers()
with http.server.HTTPServer(
("127.0.0.1", 0), make_mock_http_handler(handler)
) as server:
thread = start_server_thread(server)
try:
with socket.create_connection(server.server_address, timeout=2) as client:
framing = (
"Transfer-Encoding: chunked" if chunked else "Content-Length: 7"
)
client.sendall(
f"POST / HTTP/1.0\r\nHost: localhost\r\n{framing}\r\n\r\n".encode()
)
client.settimeout(0.1)
with pytest.raises(TimeoutError):
client.recv(1024)
client.settimeout(2)
client.sendall(body)
assert client.recv(1024).startswith(b"HTTP/1.0 200")
finally:
server.shutdown()
thread.join()
assert received == ([body] if read_body else [])
@pytest.mark.parametrize("disconnect", [False, True])
def test_mock_http_handler_allows_early_failure(disconnect):
def handler(request):
if disconnect:
request.connection.shutdown(socket.SHUT_RDWR)
request.close_connection = True
else:
request.send_response(413)
request.end_headers()
with http.server.HTTPServer(
("127.0.0.1", 0), make_mock_http_handler(handler, buffer_body=False)
) as server:
thread = start_server_thread(server)
try:
with socket.create_connection(server.server_address, timeout=2) as client:
client.sendall(
b"POST / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 7\r\n\r\n"
)
response = client.recv(1024)
if disconnect:
assert response == b""
else:
assert response.startswith(b"HTTP/1.0 413")
finally:
server.shutdown()
thread.join()
@pytest.mark.parametrize("db_name", ["a" * 64, "invalid..database"])
def test_connect_rejects_invalid_cloud_dns_hostname(db_name):
with pytest.raises(ValueError, match="DNS labels must contain 1 to 63 bytes"):
@@ -80,9 +184,9 @@ def test_connect_rejects_invalid_cloud_dns_hostname(db_name):
@contextlib.contextmanager
def mock_lancedb_connection(handler, **client_config):
def mock_lancedb_connection(handler, *, buffer_body=True, **client_config):
with http.server.ThreadingHTTPServer(
("127.0.0.1", 0), make_mock_http_handler(handler)
("127.0.0.1", 0), make_mock_http_handler(handler, buffer_body=buffer_body)
) as server:
port = server.server_address[1]
handle = start_server_thread(server)
@@ -108,9 +212,9 @@ def mock_lancedb_connection(handler, **client_config):
@contextlib.asynccontextmanager
async def mock_lancedb_connection_async(handler, **client_config):
async def mock_lancedb_connection_async(handler, *, buffer_body=True, **client_config):
with http.server.HTTPServer(
("127.0.0.1", 0), make_mock_http_handler(handler)
("127.0.0.1", 0), make_mock_http_handler(handler, buffer_body=buffer_body)
) as server:
port = server.server_address[1]
handle = start_server_thread(server)
@@ -135,6 +239,76 @@ async def mock_lancedb_connection_async(handler, **client_config):
handle.join()
@pytest.mark.parametrize("sync_api", [False, True])
@pytest.mark.parametrize("retries", [None, 0])
@pytest.mark.parametrize("status", [429, 504])
@pytest.mark.asyncio
async def test_add_retry_override(sync_api, retries, status):
attempts = 0
def handler(request):
nonlocal attempts
if request.path.endswith("/describe/"):
payload = {
"version": 1,
"schema": {
"fields": [
{"name": "id", "type": {"type": "int64"}, "nullable": True}
]
},
}
request.send_response(200)
request.end_headers()
request.wfile.write(json.dumps(payload).encode())
return
assert request.path.endswith("/insert/")
body = bytearray()
if request.headers.get("Transfer-Encoding") == "chunked":
while True:
size = int(request.rfile.readline().strip(), 16)
if size == 0:
request.rfile.readline()
break
body.extend(request.rfile.read(size))
request.rfile.read(2)
else:
body.extend(
request.rfile.read(int(request.headers.get("Content-Length", 0)))
)
assert pa.ipc.open_stream(body).read_all().to_pylist() == [{"id": 1}]
attempts += 1
request.send_response(status if attempts == 1 else 200)
request.end_headers()
request.wfile.write(b'{"version": 2}')
config = {
"retry_config": {
"retries": 3,
"statuses": [429, 504],
"backoff_factor": 0,
"backoff_jitter": 0,
}
}
expected_success = status == 429 and retries is None
if sync_api:
with mock_lancedb_connection(handler, **config) as db:
table = db.open_table("test")
if expected_success:
assert table.add([{"id": 1}], retries=retries).version == 2
else:
with pytest.raises((HttpError, RetryError)):
table.add([{"id": 1}], retries=retries)
else:
async with mock_lancedb_connection_async(handler, **config) as db:
table = await db.open_table("test")
if expected_success:
assert (await table.add([{"id": 1}], retries=retries)).version == 2
else:
with pytest.raises((HttpError, RetryError)):
await table.add([{"id": 1}], retries=retries)
assert attempts == (2 if expected_success else 1)
@pytest.fixture
def missing_table_handler(cached, namespace_path):
request_ids = []
@@ -1248,7 +1422,7 @@ async def test_create_table_timeout_is_actionable(timeout_config):
request.close_connection = True
async with mock_lancedb_connection_async(
handler, timeout_config=timeout_config
handler, buffer_body=False, timeout_config=timeout_config
) as db:
batch = pa.record_batch({"id": [1, 2]})
reader = pa.RecordBatchReader.from_batches(batch.schema, [batch])
@@ -5076,10 +5250,14 @@ def test_remote_schema_restores_blob_extensions(use_async, with_pylance, nested)
@pytest.mark.asyncio
@pytest.mark.parametrize("use_async", [False, True])
@pytest.mark.parametrize("mode", ["merge", "overwrite"])
@pytest.mark.parametrize(
"mode,part_status",
[("merge", 200), ("overwrite", 200), ("overwrite", 503)],
ids=["merge", "overwrite", "overwrite-retry"],
)
@pytest.mark.parametrize("target_with_ranges", [False, True])
async def test_remote_writes_normalize_blob_layouts(
use_async, mode, target_with_ranges
use_async, mode, part_status, target_with_ranges, mem_db
):
description = json.loads(json.dumps(BLOB_DESCRIBE_RESPONSE))
if target_with_ranges:
@@ -5088,29 +5266,77 @@ async def test_remote_writes_normalize_blob_layouts(
for name in ("position", "size")
)
received = []
paths = []
staged = {}
part_attempts = []
published = mem_db.create_table(
"published",
[{"id": 0, "image": b"before"}],
schema=pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")]),
)
def handler(request):
if request.path == "/v1/table/test/describe/":
path = urlparse(request.path).path
query = parse_qs(urlparse(request.path).query)
status = 200
if path == "/v1/table/test/describe/":
response = description
else:
expected = "merge_insert" if mode == "merge" else "insert"
assert urlparse(request.path).path == f"/v1/table/test/{expected}/"
if request.headers.get("Transfer-Encoding") == "chunked":
body = bytearray()
while size := int(request.rfile.readline().split(b";")[0], 16):
body.extend(request.rfile.read(size))
assert request.rfile.read(2) == b"\r\n"
assert request.rfile.read(2) == b"\r\n"
paths.append(path.removeprefix("/v1/table/test/"))
body = request.rfile.read()
if path == "/v1/table/test/multipart_write/create":
assert mode == "overwrite"
assert paths == ["multipart_write/create"]
assert not query and not body
response = {"upload_id": "blob-overwrite"}
elif path == "/v1/table/test/insert/":
assert mode == "overwrite"
assert query["mode"] == ["overwrite"]
assert query["upload_id"] == ["blob-overwrite"]
part_id = query["upload_part_id"][0]
assert str(uuid.UUID(part_id)) == part_id
part_attempts.append((part_id, body))
assert published.version == 1
assert published.search().select(["id"]).to_list() == [{"id": 0}]
if staged:
assert staged == {part_id: body}
else:
staged[part_id] = body
assert body[-8:] == b"\xff\xff\xff\xff\x00\x00\x00\x00"
received.append(pa.ipc.open_stream(body).read_all())
status = part_status if len(part_attempts) == 1 else 200
response = {}
elif path == "/v1/table/test/multipart_write/complete":
assert mode == "overwrite"
assert query == {"upload_id": ["blob-overwrite"]} and not body
expected_attempts = 2 if part_status == 503 else 1
assert paths == ["multipart_write/create"] + [
"insert/"
] * expected_attempts + ["multipart_write/complete"]
assert len(staged) == len(received) == 1
assert part_attempts == [part_attempts[0]] * expected_attempts
assert published.version == 1
published.add(received[0], mode="overwrite")
response = {"version": published.version}
else:
body = request.rfile.read(int(request.headers["Content-Length"]))
received.append(pa.ipc.open_stream(body).read_all())
response = {
"version": 2,
"num_updated_rows": 0,
"num_inserted_rows": 2,
"num_deleted_rows": 0,
}
request.send_response(200)
assert mode == "merge"
assert path == "/v1/table/test/merge_insert/"
# merge_insert still streams a chunked body; preserve its framing.
if request.headers.get("Transfer-Encoding") == "chunked":
stream = io.BytesIO(body)
body = bytearray()
while size := int(stream.readline().split(b";")[0], 16):
body.extend(stream.read(size))
assert stream.read(2) == b"\r\n"
assert stream.read(2) == b"\r\n"
received.append(pa.ipc.open_stream(body).read_all())
response = {
"version": 2,
"num_updated_rows": 0,
"num_inserted_rows": 2,
"num_deleted_rows": 0,
}
request.send_response(status)
request.send_header("Content-Type", "application/json")
request.send_header("phalanx-version", "0.5.0")
request.end_headers()
@@ -5143,7 +5369,23 @@ async def test_remote_writes_normalize_blob_layouts(
else:
result = table.add(data, mode="overwrite")
if use_async:
await result
result = await result
assert result.version == 2
if mode == "overwrite":
assert published.version == 2
hits = published.search().select(["id"]).with_row_id(True).to_arrow()
by_id = dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
assert set(by_id) == {1, 2}
row_ids = [by_id[1], by_id[2]]
assert published.fetch_blobs("image", row_ids).to_pylist() == [b"payload", None]
payload, null = published.fetch_blob_files("image", row_ids)
assert null is None
with payload:
assert payload.read_range(1, 3) == b"ayl"
assert payload.read() == b"payload"
else:
assert paths == ["merge_insert/"]
assert len(received) == 1
images = received[0]["image"].combine_chunks()
+5 -1
View File
@@ -1471,7 +1471,7 @@ impl Table {
})
}
#[pyo3(signature = (data, mode, progress=None, write_parallelism=None, allow_external_blob_outside_bases=false))]
#[pyo3(signature = (data, mode, progress=None, write_parallelism=None, allow_external_blob_outside_bases=false, retries=None))]
pub fn add<'a>(
self_: PyRef<'a, Self>,
data: PyScannable,
@@ -1479,6 +1479,7 @@ impl Table {
progress: Option<Py<PyAny>>,
write_parallelism: Option<usize>,
allow_external_blob_outside_bases: bool,
retries: Option<u8>,
) -> PyResult<Bound<'a, PyAny>> {
let mut op = self_
.inner_ref()?
@@ -1491,6 +1492,9 @@ impl Table {
} else {
return Err(PyValueError::new_err(format!("Invalid mode: {}", mode)));
}
if let Some(retries) = retries {
op = op.retries(retries);
}
if let Some(write_parallelism) = write_parallelism {
op = op.write_parallelism(write_parallelism);
}