arrowsql 0.1.0__cp313-cp313-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arrowsql/__init__.py +653 -0
- arrowsql/_rust_auth.py +170 -0
- arrowsql/_server_sqlstate.py +11 -0
- arrowsql/_utf8_strings.py +78 -0
- arrowsql/agent.py +620 -0
- arrowsql/aio.py +533 -0
- arrowsql/arrow_parallel.py +464 -0
- arrowsql/async_query/__init__.py +40 -0
- arrowsql/async_query/_connection_context.py +39 -0
- arrowsql/async_query/_native.py +24 -0
- arrowsql/async_query/async_connection.py +160 -0
- arrowsql/async_query/async_cursor.py +225 -0
- arrowsql/async_query/async_execute.py +113 -0
- arrowsql/async_query/async_fetch.py +153 -0
- arrowsql/async_query/exception_translator.py +132 -0
- arrowsql/auth.py +913 -0
- arrowsql/bulk_parallel.py +385 -0
- arrowsql/connection.py +2377 -0
- arrowsql/connection_string_builder.py +114 -0
- arrowsql/connection_string_parser.py +417 -0
- arrowsql/constants.py +701 -0
- arrowsql/cursor.py +4516 -0
- arrowsql/db_connection.py +98 -0
- arrowsql/ddbc_bindings.cp313-win_amd64.pyd +0 -0
- arrowsql/ddbc_bindings.pyi +283 -0
- arrowsql/decimal_config.py +98 -0
- arrowsql/exceptions.py +378 -0
- arrowsql/helpers.py +436 -0
- arrowsql/logging.py +700 -0
- arrowsql/mssql_python.pyi +579 -0
- arrowsql/odbc_provider.py +269 -0
- arrowsql/parameter_helper.py +442 -0
- arrowsql/perf_timer.py +286 -0
- arrowsql/pooling.py +147 -0
- arrowsql/py.typed +1 -0
- arrowsql/row.py +462 -0
- arrowsql/type.py +160 -0
- arrowsql-0.1.0.dist-info/METADATA +146 -0
- arrowsql-0.1.0.dist-info/RECORD +45 -0
- arrowsql-0.1.0.dist-info/WHEEL +4 -0
- arrowsql-0.1.0.dist-info/entry_points.txt +2 -0
- arrowsql-0.1.0.dist-info/licenses/LICENSE +21 -0
- arrowsql-0.1.0.dist-info/licenses/LICENSE.upstream-mssql-python +47 -0
- arrowsql-0.1.0.dist-info/sboms/ddbc_bindings.cyclonedx.json +5195 -0
- mssql_python/__init__.py +39 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""RUST-PORT: parallel bulk load.
|
|
2
|
+
|
|
3
|
+
One bulk-copy stream is limited by the client-side encoder, not by the server.
|
|
4
|
+
``write_arrow`` (Arrow sources) and ``write_rows`` (Python row tuples) cut the
|
|
5
|
+
source into slices, hand them to N connections, and run one bulk copy per
|
|
6
|
+
connection.
|
|
7
|
+
|
|
8
|
+
The target decides the policy (``_TARGET_QUERY`` detects it):
|
|
9
|
+
|
|
10
|
+
* heap: TABLOCK on every worker. Bulk-update locks are compatible with each
|
|
11
|
+
other, so the streams load the same heap at once.
|
|
12
|
+
* clustered columnstore: parallel, without TABLOCK (a table lock serializes
|
|
13
|
+
the loads; concurrent loads without it are the documented way to scale a
|
|
14
|
+
columnstore load) and with batches of at least 102,400 rows so full
|
|
15
|
+
rowgroups compress directly instead of landing in the delta store. Each
|
|
16
|
+
worker's last partial batch ends up in an open delta rowgroup that the tuple
|
|
17
|
+
mover compresses later.
|
|
18
|
+
* heap with nonclustered indexes, or a clustered rowstore index: one worker
|
|
19
|
+
and a ``RuntimeWarning`` (measured: TABLOCK workers into a heap with a
|
|
20
|
+
nonclustered index were slower than one).
|
|
21
|
+
|
|
22
|
+
Row order is not kept. Rows committed by a worker before another worker fails
|
|
23
|
+
stay in the table; the first error is raised.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import itertools
|
|
27
|
+
import queue
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import warnings
|
|
31
|
+
from contextlib import closing
|
|
32
|
+
from concurrent.futures import ThreadPoolExecutor, wait
|
|
33
|
+
from typing import Any, Optional
|
|
34
|
+
|
|
35
|
+
from arrowsql.db_connection import connect
|
|
36
|
+
|
|
37
|
+
# Cap on rows per slice. Smaller slices spread work across workers; larger
|
|
38
|
+
# ones amortize per-batch overhead.
|
|
39
|
+
_MAX_SLICE_ROWS = 65_536
|
|
40
|
+
_SLICES_PER_WORKER = 4
|
|
41
|
+
_POLL_SECONDS = 0.1
|
|
42
|
+
_END = object()
|
|
43
|
+
|
|
44
|
+
# One round trip: the target kind and the database recovery model. Index types:
|
|
45
|
+
# 1 clustered, 2 nonclustered, 5 clustered columnstore, 6 nonclustered columnstore.
|
|
46
|
+
_TARGET_QUERY = """
|
|
47
|
+
SELECT
|
|
48
|
+
CASE WHEN OBJECT_ID(?) IS NULL THEN NULL
|
|
49
|
+
WHEN EXISTS (SELECT 1 FROM sys.indexes
|
|
50
|
+
WHERE object_id = OBJECT_ID(?) AND type = 5) THEN 'columnstore'
|
|
51
|
+
WHEN EXISTS (SELECT 1 FROM sys.indexes
|
|
52
|
+
WHERE object_id = OBJECT_ID(?) AND type = 1) THEN 'clustered'
|
|
53
|
+
WHEN EXISTS (SELECT 1 FROM sys.indexes
|
|
54
|
+
WHERE object_id = OBJECT_ID(?) AND type IN (2, 6)) THEN 'heap_nci'
|
|
55
|
+
ELSE 'heap' END,
|
|
56
|
+
(SELECT recovery_model_desc FROM sys.databases WHERE database_id = DB_ID())
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Fewest rows per batch for a columnstore load: smaller batches land in the delta store.
|
|
60
|
+
_COLUMNSTORE_MIN_BATCH = 102_400
|
|
61
|
+
|
|
62
|
+
_SINGLE_WORKER_REASON = {
|
|
63
|
+
"clustered": "it has a clustered index",
|
|
64
|
+
"heap_nci": "it is a heap with nonclustered indexes, where parallel TABLOCK loads are slower",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_arrow(
|
|
69
|
+
connection_str: str,
|
|
70
|
+
table: str,
|
|
71
|
+
source: Any,
|
|
72
|
+
workers: int = 4,
|
|
73
|
+
batch_size: int = 0,
|
|
74
|
+
table_lock: bool = True,
|
|
75
|
+
timeout: int = 30,
|
|
76
|
+
column_mappings: Optional[list] = None,
|
|
77
|
+
keep_identity: bool = False,
|
|
78
|
+
check_constraints: bool = False,
|
|
79
|
+
keep_nulls: bool = False,
|
|
80
|
+
fire_triggers: bool = False,
|
|
81
|
+
**connect_kwargs: Any,
|
|
82
|
+
) -> dict:
|
|
83
|
+
"""Bulk-load an Arrow source into ``table`` over ``workers`` connections.
|
|
84
|
+
|
|
85
|
+
``source`` takes the same forms as :meth:`Cursor.bulkcopy_arrow`. With
|
|
86
|
+
``workers > 1`` the source is cut into slices that workers pull from a
|
|
87
|
+
shared queue, so faster connections take more of the load. With
|
|
88
|
+
``workers > 1`` the target decides the lock: TABLOCK for a heap, none for a
|
|
89
|
+
clustered columnstore (batches are raised to at least 102,400 rows), and
|
|
90
|
+
``workers`` falls back to 1 with a warning for other indexed targets
|
|
91
|
+
(``table_lock`` then applies as given).
|
|
92
|
+
|
|
93
|
+
Returns a dict with ``rows_copied``, ``batch_count``, ``elapsed_time``,
|
|
94
|
+
``rows_per_second`` and ``workers`` (connections used).
|
|
95
|
+
|
|
96
|
+
Raises the first error from any worker or from reading the source. The
|
|
97
|
+
other workers stop taking new slices. ``workers > 1`` needs pyarrow to cut
|
|
98
|
+
the source.
|
|
99
|
+
"""
|
|
100
|
+
_check_args(workers, batch_size, timeout)
|
|
101
|
+
bulk_options = {
|
|
102
|
+
"batch_size": batch_size,
|
|
103
|
+
"timeout": timeout,
|
|
104
|
+
"column_mappings": column_mappings,
|
|
105
|
+
"keep_identity": keep_identity,
|
|
106
|
+
"check_constraints": check_constraints,
|
|
107
|
+
"keep_nulls": keep_nulls,
|
|
108
|
+
"fire_triggers": fire_triggers,
|
|
109
|
+
}
|
|
110
|
+
started = time.perf_counter()
|
|
111
|
+
|
|
112
|
+
if workers > 1:
|
|
113
|
+
workers, table_lock, bulk_options = _apply_target_policy(
|
|
114
|
+
"write_arrow", connection_str, table, workers, table_lock, bulk_options, connect_kwargs
|
|
115
|
+
)
|
|
116
|
+
if workers == 1:
|
|
117
|
+
result = _load_single(connection_str, table, source, table_lock, bulk_options, connect_kwargs)
|
|
118
|
+
return _summarize([result], 1, started)
|
|
119
|
+
|
|
120
|
+
slice_rows = _slice_rows(source, workers)
|
|
121
|
+
results = _load_parallel(
|
|
122
|
+
connection_str,
|
|
123
|
+
workers,
|
|
124
|
+
_slices(source, slice_rows),
|
|
125
|
+
lambda cursor, first, pieces: _load_arrow_pieces(
|
|
126
|
+
cursor, table, first, pieces, table_lock, bulk_options
|
|
127
|
+
),
|
|
128
|
+
connect_kwargs,
|
|
129
|
+
)
|
|
130
|
+
return _summarize(results, workers, started)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_rows(
|
|
134
|
+
connection_str: str,
|
|
135
|
+
table: str,
|
|
136
|
+
rows: Any,
|
|
137
|
+
workers: int = 4,
|
|
138
|
+
batch_size: int = 0,
|
|
139
|
+
timeout: int = 30,
|
|
140
|
+
column_mappings: Optional[list] = None,
|
|
141
|
+
keep_identity: bool = False,
|
|
142
|
+
check_constraints: bool = False,
|
|
143
|
+
keep_nulls: bool = False,
|
|
144
|
+
fire_triggers: bool = False,
|
|
145
|
+
**connect_kwargs: Any,
|
|
146
|
+
) -> dict:
|
|
147
|
+
"""Bulk-load row tuples into ``table`` over ``workers`` connections.
|
|
148
|
+
|
|
149
|
+
``rows`` is an iterable of tuples (the :meth:`Cursor.bulkcopy` input). A
|
|
150
|
+
list or tuple is cut with plain slicing; any other iterable is read by a
|
|
151
|
+
feeder thread in chunks. Workers pull chunks from a bounded queue and each
|
|
152
|
+
runs one ``cursor.bulkcopy`` with TABLOCK.
|
|
153
|
+
|
|
154
|
+
Converting Python values holds the GIL, so the speed-up is bounded by the
|
|
155
|
+
share of a bulk copy spent on the network and the server. Row order is not
|
|
156
|
+
kept. If a worker fails, the others stop taking new rows and the first
|
|
157
|
+
error is raised; rows already committed stay in the table.
|
|
158
|
+
|
|
159
|
+
Returns a dict with ``rows_copied``, ``batch_count``, ``elapsed_time``,
|
|
160
|
+
``rows_per_second`` and ``workers`` (connections used).
|
|
161
|
+
"""
|
|
162
|
+
_check_args(workers, batch_size, timeout)
|
|
163
|
+
bulk_options = {
|
|
164
|
+
"batch_size": batch_size,
|
|
165
|
+
"timeout": timeout,
|
|
166
|
+
"column_mappings": column_mappings,
|
|
167
|
+
"keep_identity": keep_identity,
|
|
168
|
+
"check_constraints": check_constraints,
|
|
169
|
+
"keep_nulls": keep_nulls,
|
|
170
|
+
"fire_triggers": fire_triggers,
|
|
171
|
+
}
|
|
172
|
+
started = time.perf_counter()
|
|
173
|
+
|
|
174
|
+
table_lock = True
|
|
175
|
+
if workers > 1:
|
|
176
|
+
workers, table_lock, bulk_options = _apply_target_policy(
|
|
177
|
+
"write_rows", connection_str, table, workers, True, bulk_options, connect_kwargs
|
|
178
|
+
)
|
|
179
|
+
if workers == 1:
|
|
180
|
+
with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
|
|
181
|
+
with closing(conn.cursor()) as cursor:
|
|
182
|
+
result = cursor.bulkcopy(table, rows, table_lock=table_lock, **bulk_options)
|
|
183
|
+
return _summarize([result], 1, started)
|
|
184
|
+
|
|
185
|
+
results = _load_parallel(
|
|
186
|
+
connection_str,
|
|
187
|
+
workers,
|
|
188
|
+
_row_slices(rows, workers),
|
|
189
|
+
lambda cursor, first, pieces: _load_row_pieces(
|
|
190
|
+
cursor, table, first, pieces, table_lock, bulk_options
|
|
191
|
+
),
|
|
192
|
+
connect_kwargs,
|
|
193
|
+
)
|
|
194
|
+
return _summarize(results, workers, started)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _check_args(workers, batch_size, timeout):
|
|
198
|
+
if not isinstance(workers, int) or isinstance(workers, bool) or workers < 1:
|
|
199
|
+
raise ValueError(f"workers must be a positive integer, got {workers!r}")
|
|
200
|
+
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 0:
|
|
201
|
+
raise ValueError(f"batch_size must be a non-negative integer, got {batch_size!r}")
|
|
202
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout < 0:
|
|
203
|
+
raise ValueError(f"timeout must be a non-negative integer, got {timeout!r}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _apply_target_policy(caller, connection_str, table, workers, table_lock, bulk_options, connect_kwargs):
|
|
207
|
+
"""Return ``(workers, table_lock, bulk_options)`` for the target's kind."""
|
|
208
|
+
with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
|
|
209
|
+
with closing(conn.cursor()) as cursor:
|
|
210
|
+
cursor.execute(_TARGET_QUERY, (table, table, table, table))
|
|
211
|
+
kind, recovery_model = cursor.fetchone()
|
|
212
|
+
if recovery_model == "FULL":
|
|
213
|
+
warnings.warn(
|
|
214
|
+
f"{caller}: the database uses the FULL recovery model, so bulk loads are fully logged; "
|
|
215
|
+
"BULK_LOGGED or SIMPLE is much faster for large loads",
|
|
216
|
+
RuntimeWarning,
|
|
217
|
+
stacklevel=3,
|
|
218
|
+
)
|
|
219
|
+
if kind in _SINGLE_WORKER_REASON:
|
|
220
|
+
warnings.warn(
|
|
221
|
+
f"{caller}: loading {table!r} with 1 worker because {_SINGLE_WORKER_REASON[kind]}",
|
|
222
|
+
RuntimeWarning,
|
|
223
|
+
stacklevel=3,
|
|
224
|
+
)
|
|
225
|
+
return 1, table_lock, bulk_options
|
|
226
|
+
if kind == "columnstore":
|
|
227
|
+
if bulk_options["batch_size"] < _COLUMNSTORE_MIN_BATCH:
|
|
228
|
+
bulk_options = {**bulk_options, "batch_size": _COLUMNSTORE_MIN_BATCH}
|
|
229
|
+
return workers, False, bulk_options
|
|
230
|
+
return workers, True, bulk_options
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _slice_size(count, workers):
|
|
234
|
+
"""Slice size that gives each worker several slices to pull."""
|
|
235
|
+
return max(1, min(_MAX_SLICE_ROWS, -(-count // (workers * _SLICES_PER_WORKER))))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _slice_rows(source, workers):
|
|
239
|
+
num_rows = getattr(source, "num_rows", None)
|
|
240
|
+
if isinstance(num_rows, int) and num_rows > 0:
|
|
241
|
+
return _slice_size(num_rows, workers)
|
|
242
|
+
return _MAX_SLICE_ROWS
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _load_single(connection_str, table, source, table_lock, bulk_options, connect_kwargs):
|
|
246
|
+
with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
|
|
247
|
+
with closing(conn.cursor()) as cursor:
|
|
248
|
+
return cursor.bulkcopy_arrow(table, source, table_lock=table_lock, **bulk_options)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_parallel(connection_str, workers, pieces, load, connect_kwargs):
|
|
252
|
+
"""Feed ``pieces`` to ``workers`` connections. ``load(cursor, first, pieces)``
|
|
253
|
+
runs one bulk copy over the pieces a worker pulls and returns its result."""
|
|
254
|
+
stop = threading.Event()
|
|
255
|
+
failures: list = []
|
|
256
|
+
pending = queue.Queue(maxsize=workers * 4)
|
|
257
|
+
|
|
258
|
+
def worker():
|
|
259
|
+
try:
|
|
260
|
+
return _load_worker(connection_str, pending, stop, load, connect_kwargs)
|
|
261
|
+
except BaseException as exc:
|
|
262
|
+
failures.append(exc)
|
|
263
|
+
stop.set()
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
def feed():
|
|
267
|
+
try:
|
|
268
|
+
for piece in pieces:
|
|
269
|
+
if not _put(pending, piece, stop):
|
|
270
|
+
return
|
|
271
|
+
except BaseException as exc:
|
|
272
|
+
failures.append(exc)
|
|
273
|
+
stop.set()
|
|
274
|
+
finally:
|
|
275
|
+
for _ in range(workers):
|
|
276
|
+
_put(pending, _END, stop)
|
|
277
|
+
|
|
278
|
+
with ThreadPoolExecutor(max_workers=workers + 1, thread_name_prefix="mssql-bulk") as pool:
|
|
279
|
+
futures = [pool.submit(worker) for _ in range(workers)]
|
|
280
|
+
wait([pool.submit(feed), *futures])
|
|
281
|
+
if failures:
|
|
282
|
+
raise failures[0]
|
|
283
|
+
results = [future.result() for future in futures]
|
|
284
|
+
return [r for r in results if r]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _load_worker(connection_str, pending, stop, load, connect_kwargs):
|
|
288
|
+
with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
|
|
289
|
+
with closing(conn.cursor()) as cursor:
|
|
290
|
+
first = _next(pending, stop)
|
|
291
|
+
if first is _END:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
def pieces():
|
|
295
|
+
piece = first
|
|
296
|
+
while piece is not _END:
|
|
297
|
+
yield piece
|
|
298
|
+
piece = _next(pending, stop)
|
|
299
|
+
|
|
300
|
+
return load(cursor, first, pieces())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _load_arrow_pieces(cursor, table, first, pieces, table_lock, bulk_options):
|
|
304
|
+
import pyarrow as pa
|
|
305
|
+
|
|
306
|
+
# A RecordBatchReader is an Arrow C stream, which the native bulk
|
|
307
|
+
# copy consumes directly; a bare generator would need mssql_py_core.
|
|
308
|
+
reader = pa.RecordBatchReader.from_batches(first.schema, pieces)
|
|
309
|
+
return cursor.bulkcopy_arrow(table, reader, table_lock=table_lock, **bulk_options)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _load_row_pieces(cursor, table, first, pieces, table_lock, bulk_options):
|
|
313
|
+
rows = itertools.chain.from_iterable(pieces)
|
|
314
|
+
return cursor.bulkcopy(table, rows, table_lock=table_lock, **bulk_options)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _row_slices(rows, workers):
|
|
318
|
+
"""Yield lists of rows: plain slices of a list or tuple, else islice chunks."""
|
|
319
|
+
if isinstance(rows, (list, tuple)):
|
|
320
|
+
count = len(rows)
|
|
321
|
+
size = _slice_size(count, workers)
|
|
322
|
+
for offset in range(0, count, size):
|
|
323
|
+
yield rows[offset : offset + size]
|
|
324
|
+
return
|
|
325
|
+
iterator = iter(rows)
|
|
326
|
+
while True:
|
|
327
|
+
chunk = list(itertools.islice(iterator, _MAX_SLICE_ROWS))
|
|
328
|
+
if not chunk:
|
|
329
|
+
return
|
|
330
|
+
yield chunk
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _slices(source, slice_rows):
|
|
334
|
+
"""Yield non-empty RecordBatch slices of at most ``slice_rows`` rows."""
|
|
335
|
+
for batch in _record_batches(source):
|
|
336
|
+
for offset in range(0, batch.num_rows, slice_rows):
|
|
337
|
+
yield batch.slice(offset, slice_rows)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _record_batches(source):
|
|
341
|
+
import pyarrow as pa
|
|
342
|
+
|
|
343
|
+
if isinstance(source, pa.Table):
|
|
344
|
+
return source.to_batches()
|
|
345
|
+
if isinstance(source, pa.RecordBatch):
|
|
346
|
+
return [source]
|
|
347
|
+
if isinstance(source, pa.RecordBatchReader) or hasattr(source, "__arrow_c_stream__"):
|
|
348
|
+
return pa.RecordBatchReader.from_stream(source)
|
|
349
|
+
if hasattr(source, "__arrow_c_array__"):
|
|
350
|
+
return [pa.record_batch(source)]
|
|
351
|
+
return source
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _put(pending, item, stop):
|
|
355
|
+
"""Put ``item`` unless the load was stopped. Returns False if stopped."""
|
|
356
|
+
while not stop.is_set():
|
|
357
|
+
try:
|
|
358
|
+
pending.put(item, timeout=_POLL_SECONDS)
|
|
359
|
+
return True
|
|
360
|
+
except queue.Full:
|
|
361
|
+
continue
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _next(pending, stop):
|
|
366
|
+
"""Next slice, or ``_END`` when the feed is done or the load was stopped."""
|
|
367
|
+
while not stop.is_set():
|
|
368
|
+
try:
|
|
369
|
+
return pending.get(timeout=_POLL_SECONDS)
|
|
370
|
+
except queue.Empty:
|
|
371
|
+
continue
|
|
372
|
+
return _END
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _summarize(results, workers, started):
|
|
376
|
+
rows = sum(r.get("rows_copied", 0) for r in results)
|
|
377
|
+
batches = sum(r.get("batch_count", 0) for r in results)
|
|
378
|
+
elapsed = time.perf_counter() - started
|
|
379
|
+
return {
|
|
380
|
+
"rows_copied": rows,
|
|
381
|
+
"batch_count": batches,
|
|
382
|
+
"elapsed_time": elapsed,
|
|
383
|
+
"rows_per_second": rows / elapsed if elapsed > 0 else 0.0,
|
|
384
|
+
"workers": workers,
|
|
385
|
+
}
|