Skip to content

Commit 83ac17c

Browse files
authored
feat: support resumable trigger batch deletion (#973)
1 parent 57e4349 commit 83ac17c

6 files changed

Lines changed: 105 additions & 24 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.13.25"
3+
version = "0.14.0"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath>=2.12.7, <2.13.0",
8+
"uipath>=2.13.0, <2.14.0",
99
"uipath-core>=0.5.28, <0.6.0",
10-
"uipath-platform>=0.1.91, <0.2.0",
11-
"uipath-runtime>=0.11.7, <0.12.0",
10+
"uipath-platform>=0.2.0, <0.3.0",
11+
"uipath-runtime>=0.12.1, <0.13.0",
1212
"langgraph>=1.1.8, <2.0.0",
1313
"langchain-core>=1.2.27, <2.0.0",
1414
"langgraph-checkpoint-sqlite>=3.0.3, <4.0.0",
@@ -169,4 +169,4 @@ jsonschema-pydantic-converter = false
169169
name = "testpypi"
170170
url = "https://test.pypi.org/simple/"
171171
publish-url = "https://test.pypi.org/legacy/"
172-
explicit = true
172+
explicit = true

src/uipath_langchain/runtime/storage.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,25 @@ async def delete_trigger(
133133
self, runtime_id: str, trigger: UiPathResumeTrigger
134134
) -> None:
135135
"""Delete resume trigger from storage."""
136+
await self.delete_triggers(runtime_id, [trigger])
137+
138+
async def delete_triggers(
139+
self, runtime_id: str, triggers: list[UiPathResumeTrigger]
140+
) -> None:
141+
"""Delete resume triggers from storage."""
136142
await self._ensure_table()
143+
interrupt_ids = [trigger.interrupt_id for trigger in triggers]
144+
if not interrupt_ids:
145+
return
137146

138147
async with self.memory.lock, self.memory.conn.cursor() as cur:
139148
await cur.execute(
140149
f"""
141150
DELETE FROM {self.rs_table_name}
142-
WHERE runtime_id = ? AND interrupt_id = ?
151+
WHERE runtime_id = ?
152+
AND interrupt_id IN ({",".join("?" for _ in interrupt_ids)})
143153
""",
144-
(
145-
runtime_id,
146-
trigger.interrupt_id,
147-
),
154+
(runtime_id, *interrupt_ids),
148155
)
149156
await self.memory.conn.commit()
150157

testcases/dev-console/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dependencies = [
88
"langchain-core>=0.3.34",
99
"python-dotenv>=1.0.1",
1010
"uipath-langchain",
11-
"uipath-dev>=0.0.12",
11+
"uipath-dev>=0.0.84",
1212
"pydantic>=2.10.6",
1313
"typing-extensions>=4.12.2",
1414
"pexpect>=4.9.0",

tests/runtime/test_resumable.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger:
4040
)
4141
return trigger
4242

43+
async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]:
44+
"""Create triggers from suspend value."""
45+
return [await self.create_trigger(suspend_value)]
46+
4347
async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any:
4448
"""Read trigger and return mock response.
4549
@@ -79,6 +83,10 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger:
7983
)
8084
return trigger
8185

86+
async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]:
87+
"""Create triggers from suspend value."""
88+
return [await self.create_trigger(suspend_value)]
89+
8290
async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any:
8391
"""Read trigger and return immediate response."""
8492
assert trigger.payload is not None
@@ -716,6 +724,10 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger:
716724
)
717725
return trigger
718726

727+
async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]:
728+
"""Create triggers from suspend value."""
729+
return [await self.create_trigger(suspend_value)]
730+
719731
async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any:
720732
"""Read trigger and return mock response based on call pattern."""
721733
self.call_count += 1

tests/runtime/test_storage.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,68 @@ async def test_delete_trigger(self, storage: SqliteResumableStorage):
193193
assert len(triggers) == 1
194194
assert triggers[0].interrupt_id == "interrupt2"
195195

196+
@pytest.mark.asyncio
197+
async def test_delete_triggers(self, storage: SqliteResumableStorage):
198+
"""Test deleting sibling triggers for the same interrupt."""
199+
api_trigger = UiPathResumeTrigger(
200+
interrupt_id="interrupt1",
201+
trigger_type=UiPathResumeTriggerType.API,
202+
trigger_name=UiPathResumeTriggerName.API,
203+
payload="api-payload",
204+
)
205+
timer_trigger = UiPathResumeTrigger(
206+
interrupt_id="interrupt1",
207+
trigger_type=UiPathResumeTriggerType.TIMER,
208+
trigger_name=UiPathResumeTriggerName.TIMER,
209+
payload="timer-payload",
210+
)
211+
unrelated_trigger = UiPathResumeTrigger(
212+
interrupt_id="interrupt2",
213+
trigger_type=UiPathResumeTriggerType.TASK,
214+
trigger_name=UiPathResumeTriggerName.TASK,
215+
payload="unrelated-payload",
216+
)
217+
other_runtime_trigger = UiPathResumeTrigger(
218+
interrupt_id="interrupt1",
219+
trigger_type=UiPathResumeTriggerType.API,
220+
trigger_name=UiPathResumeTriggerName.API,
221+
payload="other-runtime-payload",
222+
)
223+
await storage.save_triggers(
224+
"runtime1", [api_trigger, timer_trigger, unrelated_trigger]
225+
)
226+
await storage.save_triggers("runtime2", [other_runtime_trigger])
227+
228+
await storage.delete_triggers("runtime1", [api_trigger, timer_trigger])
229+
230+
triggers = await storage.get_triggers("runtime1")
231+
assert triggers is not None
232+
assert [trigger.interrupt_id for trigger in triggers] == ["interrupt2"]
233+
assert triggers[0].payload == "unrelated-payload"
234+
235+
other_triggers = await storage.get_triggers("runtime2")
236+
assert other_triggers is not None
237+
assert len(other_triggers) == 1
238+
assert other_triggers[0].payload == "other-runtime-payload"
239+
240+
@pytest.mark.asyncio
241+
async def test_delete_triggers_empty_list(self, storage: SqliteResumableStorage):
242+
"""Test deleting an empty trigger list leaves storage unchanged."""
243+
trigger = UiPathResumeTrigger(
244+
interrupt_id="interrupt1",
245+
trigger_type=UiPathResumeTriggerType.API,
246+
trigger_name=UiPathResumeTriggerName.API,
247+
payload="payload1",
248+
)
249+
await storage.save_triggers("runtime1", [trigger])
250+
251+
await storage.delete_triggers("runtime1", [])
252+
253+
triggers = await storage.get_triggers("runtime1")
254+
assert triggers is not None
255+
assert len(triggers) == 1
256+
assert triggers[0].interrupt_id == "interrupt1"
257+
196258
@pytest.mark.asyncio
197259
async def test_get_nonexistent_triggers_returns_none(
198260
self, storage: SqliteResumableStorage

uv.lock

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)