mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user