Skip to content

Commit abd25ef

Browse files
author
Max Wang
committed
remove wait and add 2 modes for multi record delete
1 parent ddca15d commit abd25ef

File tree

4 files changed

+48
-167
lines changed

4 files changed

+48
-167
lines changed

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Auth:
3939
| `update` | `update(logical_name, list[id], patch)` | `None` | Broadcast; same patch applied to all IDs (UpdateMultiple). |
4040
| `update` | `update(logical_name, list[id], list[patch])` | `None` | 1:1 patches; lengths must match (UpdateMultiple). |
4141
| `delete` | `delete(logical_name, id)` | `None` | Delete one record. |
42-
| `delete` | `delete(logical_name, list[id], ..., wait_poll_interval_seconds=2.0)` | `Optional[str]` | Delete many with async BulkDelete. |
42+
| `delete` | `delete(logical_name, list[id], use_bulk_delete=True)` | `Optional[str]` | Delete many with async BulkDelete or sequential single-record delete. |
4343
| `query_sql` | `query_sql(sql)` | `list[dict]` | Constrained read-only SELECT via `?sql=`. |
4444
| `create_table` | `create_table(tablename, schema, solution_unique_name=None)` | `dict` | Creates custom table + columns. Friendly name (e.g. `SampleItem`) becomes schema `new_SampleItem`; explicit schema name (contains `_`) used as-is. Pass `solution_unique_name` to attach the table to a specific solution instead of the default solution. |
4545
| `create_column` | `create_column(tablename, columns)` | `list[str]` | Adds columns using a `{name: type}` mapping (same shape as `create_table` schema). Returns schema names for the created columns. |
@@ -56,8 +56,8 @@ Guidelines:
5656
- `create` always returns a list of GUIDs (1 for single, N for bulk).
5757
- `update` always returns `None`.
5858
- Bulk update chooses broadcast vs per-record by the type of `changes` (dict vs list).
59-
- `delete` returns `None` for single-record delete and the BulkDelete async job ID for multi-record delete.
60-
- By default multi-record delete doesn't wait for the async job to complete. User can optionally wait for the job to complete.,klmmm
59+
- `delete` returns `None` for single-record delete and sequential multi-record delete, and the BulkDelete async job ID for multi-record BulkDelete.
60+
- BulkDelete doesn't wait for the delete job to complete. It returns once the async delete job is scheduled.
6161
- Paging and SQL operations never mutate inputs.
6262
- Metadata lookups for logical name stamping cached per entity set (in-memory).
6363

@@ -339,7 +339,8 @@ client.delete_table("SampleItem") # delete table (friendly name or explici
339339

340340
Notes:
341341
- `create` always returns a list of GUIDs (length 1 for single input).
342-
- `update` returns `None`. `delete` returns `None` for single-record delete and the BulkDelete async job ID for multi-record delete.
342+
- `update` returns `None`.
343+
- `delete` returns `None` for single-record delete/sequential multi-record delete, and the BulkDelete async job ID for BulkDelete.
343344
- Passing a list of payloads to `create` triggers bulk create and returns `list[str]` of IDs.
344345
- `get` supports single record retrieval with record id or paging through result sets (prefer `select` to limit columns).
345346
- For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required.

examples/quickstart.py

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -494,52 +494,48 @@ def run_paging_demo(label: str, *, top: Optional[int], page_size: Optional[int])
494494
single_error: Optional[str] = None
495495
bulk_job_id: Optional[str] = None
496496
bulk_error: Optional[str] = None
497-
bulk_wait_job_id: Optional[str] = None
498-
bulk_wait_error: Optional[str] = None
499-
async_targets: list[str] = []
500-
wait_targets: list[str] = []
501497

502498
try:
503499
log_call(f"client.delete('{logical}', '{single_target}')")
504500
backoff_retry(lambda: client.delete(logical, single_target))
505501
except Exception as ex:
506502
single_error = str(ex)
507503

508-
if rest_targets:
509-
half = len(rest_targets) // 2
510-
async_targets = rest_targets[:half]
511-
wait_targets = rest_targets[half:]
504+
half = max(1, len(rest_targets) // 2)
505+
bulk_targets = rest_targets[:half]
506+
sequential_targets = rest_targets[half:]
507+
bulk_error = None
508+
sequential_error = None
512509

513-
try:
514-
log_call(f"client.delete('{logical}', <{len(async_targets)} ids>) [fire-and-forget]")
515-
bulk_job_id = client.delete(logical, async_targets)
516-
except Exception as ex:
517-
bulk_error = str(ex)
518-
try:
519-
log_call(f"client.delete('{logical}', <{len(wait_targets)} ids>, wait=True)")
520-
bulk_wait_job_id = client.delete(
521-
logical,
522-
wait_targets,
523-
wait=True,
524-
)
525-
except Exception as ex:
526-
bulk_wait_error = str(ex)
510+
# Fire-and-forget bulk delete for the first portion
511+
try:
512+
log_call(f"client.delete('{logical}', <{len(bulk_targets)} ids>, use_bulk_delete=True)")
513+
bulk_job_id = client.delete(logical, bulk_targets)
514+
except Exception as ex:
515+
bulk_error = str(ex)
516+
517+
# Sequential deletes for the remainder
518+
try:
519+
log_call(f"client.delete('{logical}', <{len(sequential_targets)} ids>, use_bulk_delete=False)")
520+
for rid in sequential_targets:
521+
backoff_retry(lambda rid=rid: client.delete(logical, rid, use_bulk_delete=False))
522+
except Exception as ex:
523+
sequential_error = str(ex)
527524

528525
print({
529526
"entity": logical,
530527
"delete_single": {
531528
"id": single_target,
532529
"error": single_error,
533530
},
534-
"delete_bulk_fire_and_forget": {
535-
"count": len(async_targets) if rest_targets else 0,
531+
"delete_bulk": {
532+
"count": len(bulk_targets),
536533
"job_id": bulk_job_id,
537534
"error": bulk_error,
538535
},
539-
"delete_bulk_wait": {
540-
"count": len(wait_targets) if rest_targets else 0,
541-
"job_id": bulk_wait_job_id,
542-
"error": bulk_wait_error,
536+
"delete_sequential": {
537+
"count": len(sequential_targets),
538+
"error": sequential_error,
543539
},
544540
})
545541
else:

src/dataverse_sdk/client.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,7 @@ def delete(
205205
self,
206206
logical_name: str,
207207
ids: Union[str, List[str]],
208-
wait: bool = False,
209-
wait_timeout_seconds: Optional[int] = 300,
210-
wait_poll_interval_seconds: float = 2.0,
208+
use_bulk_delete: bool = True,
211209
) -> Optional[str]:
212210
"""
213211
Delete one or more records by GUID.
@@ -216,16 +214,14 @@ def delete(
216214
:type logical_name: str
217215
:param ids: Single GUID string or list of GUID strings to delete.
218216
:type ids: str or list[str]
219-
:param wait: When deleting multiple records, wait for the background job to complete. Ignored for single deletes.
220-
:type wait: bool
221-
:param wait_timeout_seconds: Optional timeout applied when ``wait`` is True. ``None`` or
222-
values ``<= 0`` wait indefinitely. Defaults to 300 seconds.
223-
:type wait_timeout_seconds: int or None
224-
:param wait_poll_interval_seconds: Poll interval used while waiting for job completion.
225-
:type wait_poll_interval_seconds: float
217+
:param use_bulk_delete: When ``True`` (default) and ``ids`` is a list, execute the BulkDelete action and
218+
return its async job identifier. When ``False`` each record is deleted sequentially.
219+
:type use_bulk_delete: bool
220+
226221
:raises TypeError: If ``ids`` is not str or list[str].
222+
:raises HttpError: If the underlying Web API delete request fails.
227223
228-
:return: BulkDelete job ID when deleting multiple records; otherwise ``None``.
224+
:return: BulkDelete job ID when deleting multiple records via BulkDelete; otherwise ``None``.
229225
:rtype: str or None
230226
231227
Example:
@@ -243,13 +239,15 @@ def delete(
243239
return None
244240
if not isinstance(ids, list):
245241
raise TypeError("ids must be str or list[str]")
246-
return od._delete_multiple(
247-
logical_name,
248-
ids,
249-
wait=wait,
250-
timeout_seconds=wait_timeout_seconds,
251-
poll_interval_seconds=wait_poll_interval_seconds,
252-
)
242+
if not ids:
243+
return None
244+
if not all(isinstance(rid, str) for rid in ids):
245+
raise TypeError("ids must contain string GUIDs")
246+
if use_bulk_delete:
247+
return od._delete_multiple(logical_name, ids)
248+
for rid in ids:
249+
od._delete(logical_name, rid)
250+
return None
253251

254252
def get(
255253
self,

src/dataverse_sdk/odata.py

Lines changed: 3 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Dict, Optional, List, Union, Iterable, Tuple
3+
from typing import Any, Dict, Optional, List, Union, Iterable
44
from enum import Enum
55
import unicodedata
66
import time
@@ -286,14 +286,10 @@ def _delete_multiple(
286286
self,
287287
logical_name: str,
288288
ids: List[str],
289-
wait: bool = False,
290-
timeout_seconds: Optional[int] = 300,
291-
poll_interval_seconds: float = 2.0,
292289
) -> Optional[str]:
293290
"""Delete many records by GUID list.
294291
295-
Returns the asynchronous job identifier. When ``wait`` is True the call blocks until the
296-
async operation completes or the timeout elapses.
292+
Returns the asynchronous job identifier reported by the BulkDelete action.
297293
"""
298294
if not isinstance(ids, list):
299295
raise TypeError("ids must be list[str]")
@@ -308,8 +304,6 @@ def _delete_multiple(
308304
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
309305
job_label = f"Bulk delete {logical_name} records @ {timestamp}"
310306

311-
when_utc = timestamp
312-
313307
query = {
314308
"@odata.type": "Microsoft.Dynamics.CRM.QueryExpression",
315309
"EntityName": logical_name,
@@ -338,7 +332,7 @@ def _delete_multiple(
338332
"ToRecipients": [],
339333
"CCRecipients": [],
340334
"RecurrencePattern": "",
341-
"StartDateTime": when_utc,
335+
"StartDateTime": timestamp,
342336
"QuerySet": [query],
343337
}
344338

@@ -353,116 +347,8 @@ def _delete_multiple(
353347
if isinstance(body, dict):
354348
job_id = body.get("JobId")
355349

356-
if wait and job_id:
357-
payload, succeeded = self._wait_for_async_job(
358-
job_id,
359-
timeout_seconds=timeout_seconds,
360-
poll_interval=poll_interval_seconds,
361-
)
362-
if not succeeded:
363-
state = payload.get("statecode")
364-
status = payload.get("statuscode")
365-
message = payload.get("message")
366-
raise RuntimeError(
367-
f"Bulk delete async job '{job_id}' did not succeed (state={state}, status={status})."
368-
+ (f" Message: {message}" if message else "")
369-
)
370-
371350
return job_id
372351

373-
def _wait_for_async_job(
374-
self,
375-
job_id: str,
376-
timeout_seconds: Optional[int] = 300,
377-
poll_interval: float = 2.0,
378-
) -> Tuple[Dict[str, Any], bool]:
379-
"""Poll the asyncoperation record until completion or timeout.
380-
381-
Returns the last payload and a boolean indicating success.
382-
"""
383-
if not job_id:
384-
return {}, False
385-
386-
interval = poll_interval if poll_interval and poll_interval > 0 else 2.0
387-
deadline = None
388-
if timeout_seconds is not None and timeout_seconds > 0:
389-
deadline = time.time() + timeout_seconds
390-
391-
url = f"{self.api}/asyncoperations({job_id})"
392-
params = {"$select": "statecode,statuscode,name,message"}
393-
last_payload: Dict[str, Any] = {}
394-
395-
while True:
396-
now = time.time()
397-
if deadline and now >= deadline:
398-
message = last_payload.get("message") if last_payload else None
399-
raise TimeoutError(
400-
f"Timed out waiting for async job '{job_id}' to complete."
401-
+ (f" Last message: {message}" if message else "")
402-
)
403-
404-
try:
405-
response = self._request("get", url, params=params)
406-
try:
407-
payload = response.json() if response.text else {}
408-
except ValueError:
409-
payload = {}
410-
if isinstance(payload, dict):
411-
last_payload = payload
412-
else:
413-
last_payload = {}
414-
except HttpError as err:
415-
if getattr(err, "status_code", None) == 404:
416-
# The job record might not be immediately available yet; retry until timeout.
417-
time.sleep(interval)
418-
continue
419-
raise
420-
421-
state = last_payload.get("statecode")
422-
status = last_payload.get("statuscode")
423-
finished, succeeded = self._interpret_async_job_state(state, status)
424-
if finished:
425-
return last_payload, succeeded
426-
427-
time.sleep(interval)
428-
429-
@staticmethod
430-
def _interpret_async_job_state(state_raw: Any, status_raw: Any) -> Tuple[bool, bool]:
431-
"""Return (finished, succeeded) flags for an asyncoperation state/status."""
432-
433-
def _norm(val: Any) -> str:
434-
if val is None:
435-
return ""
436-
if isinstance(val, str):
437-
return val.strip().lower()
438-
return str(val).strip().lower()
439-
440-
state_norm = _norm(state_raw)
441-
status_norm = _norm(status_raw)
442-
443-
finished = False
444-
succeeded = False
445-
446-
if state_norm in {"3", "completed", "complete", "succeeded"}:
447-
finished = True
448-
449-
if status_norm in {"30", "succeeded", "success", "completed"}:
450-
finished = True
451-
succeeded = True
452-
elif status_norm in {"31", "failed", "failure"}:
453-
finished = True
454-
succeeded = False
455-
elif status_norm in {"32", "33", "canceled", "cancelled"}:
456-
finished = True
457-
succeeded = False
458-
elif status_norm.isdigit():
459-
code = int(status_norm)
460-
if code >= 30:
461-
finished = True
462-
succeeded = (code == 30)
463-
464-
return finished, succeeded
465-
466352
def _format_key(self, key: str) -> str:
467353
k = key.strip()
468354
if k.startswith("(") and k.endswith(")"):

0 commit comments

Comments
 (0)