Skip to content

Commit aaa9779

Browse files
authored
Allow to filter by params (#99)
1 parent 1f957fc commit aaa9779

4 files changed

Lines changed: 102 additions & 12 deletions

File tree

docs/reference/task_plugin.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ called directly from within a task by passing `context.task_manager`:
3131

3232
```python
3333
from fluid.scheduler import TaskRun, task
34-
from fluid.scheduler.db import get_db_plugin, HistoryQuery
34+
from fluid.scheduler.db import get_db_plugin, TaskHistoryQuery
3535

3636

3737
@task()
3838
async def report(context: TaskRun) -> None:
3939
db_plugin = get_db_plugin(context.task_manager)
40-
page = await db_plugin.get_history(HistoryQuery(task="my-task", limit=10))
40+
page = await db_plugin.get_history(TaskHistoryQuery(task="my-task", limit=10))
4141
for run in page.data:
4242
print(run.id, run.state)
4343
```
@@ -53,10 +53,10 @@ or the HTTP endpoints added by [with_task_history_router][fluid.scheduler.db.wit
5353
They can be imported from `fluid.scheduler.db`:
5454

5555
```python
56-
from fluid.scheduler.db import HistoryQuery, TaskRunHistory, TaskRunHistoryPage
56+
from fluid.scheduler.db import TaskHistoryQuery, TaskRunHistory, TaskRunHistoryPage
5757
```
5858

59-
::: fluid.scheduler.db.HistoryQuery
59+
::: fluid.scheduler.db.TaskHistoryQuery
6060

6161
::: fluid.scheduler.db.TaskRunHistory
6262

fluid/db/crud.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from dateutil.parser import parse as parse_date
55
from sqlalchemy import Column, Table, func, insert, select
6+
from sqlalchemy.dialects.postgresql import JSONB
67
from sqlalchemy.engine.cursor import CursorResult
78
from sqlalchemy.engine.row import Row
89
from sqlalchemy.ext.asyncio import AsyncConnection
@@ -300,6 +301,11 @@ def default_filter_column(
300301
],
301302
) -> Any:
302303
"""Build a SQLAlchemy WHERE clause expression for a single column filter"""
304+
if isinstance(column.type, JSONB) and isinstance(value, dict):
305+
if op == "eq":
306+
return column.contains(value)
307+
return None
308+
303309
if multiple := isinstance(value, (list, tuple)):
304310
value = tuple(column_value_to_python(column, v) for v in value)
305311
else:

fluid/scheduler/db.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

3+
import json
34
from datetime import datetime
45
from typing import Any, ClassVar
56

67
import sqlalchemy as sa
78
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query
8-
from pydantic import BaseModel, Field
9+
from pydantic import BaseModel, BeforeValidator, Field, model_validator
10+
from sqlalchemy.dialects.postgresql import JSONB
911
from sqlalchemy.exc import NoResultFound
1012
from typing_extensions import Annotated, Doc
1113

@@ -20,8 +22,18 @@
2022
from .plugin import TaskManagerPlugin
2123

2224

25+
def _parse_json_str(v: Any) -> Any:
26+
"""Parse a JSON string into a dict for JSONB query parameters."""
27+
if isinstance(v, str):
28+
return json.loads(v)
29+
return v
30+
31+
32+
JsonDict = Annotated[dict[str, Any], BeforeValidator(_parse_json_str)]
33+
34+
2335
class TaskDbPlugin(TaskManagerPlugin):
24-
"""A plugin to store task runs in a database.
36+
"""A plugin to store [TaskRun][fluid.scheduler.TaskRun] in a postgresql database.
2537
2638
This plugin listens to task state changes and updates the database accordingly.
2739
It requires a CrudDB instance to perform database operations and allows
@@ -95,7 +107,7 @@ def register(self, task_manager: TaskManager) -> None:
95107
async def get_history(
96108
self,
97109
q: Annotated[
98-
HistoryQuery, Doc("Query parameters for fetching task run history")
110+
TaskHistoryQuery, Doc("Query parameters for fetching task run history")
99111
],
100112
) -> TaskRunHistoryPage:
101113
"""Get task run history based on the provided query parameters."""
@@ -169,10 +181,15 @@ def task_meta(meta: sa.MetaData, table_name: str = "tasks") -> None:
169181
nullable=False,
170182
index=True,
171183
),
172-
sa.Column("queued", sa.DateTime(timezone=True), nullable=False),
184+
sa.Column("queued", sa.DateTime(timezone=True), nullable=False, index=True),
173185
sa.Column("start", sa.DateTime(timezone=True)),
174186
sa.Column("end", sa.DateTime(timezone=True)),
175-
sa.Column("params", sa.JSON),
187+
sa.Column("params", JSONB),
188+
sa.Index(
189+
f"ix_{table_name}_params",
190+
"params",
191+
postgresql_using="gin",
192+
),
176193
)
177194

178195

@@ -228,32 +245,52 @@ class TaskRunHistoryPage(BaseModel):
228245
cursor: str = Field(..., description="Pagination cursor to fetch the next page")
229246

230247

231-
class HistoryQuery(BaseModel):
248+
class TaskHistoryQuery(BaseModel):
232249
"""Query parameters for fetching task run history."""
233250

234251
task: Annotated[
235252
str | None,
236253
Query(description="Filter by task name"),
254+
Doc("Filter by task name when provided"),
237255
] = None
238256
start: Annotated[
239257
datetime | None,
240258
Query(description="Filter runs queued at or after this time"),
259+
Doc("Filter runs queued at or after this time when provided"),
241260
] = None
242261
end: Annotated[
243262
datetime | None,
244263
Query(description="Filter runs queued at or before this time"),
264+
Doc("Filter runs queued at or before this time when provided"),
245265
] = None
246266
state: Annotated[
247267
TaskState | None,
248268
Query(description="Filter by task state"),
269+
Doc("Filter by task state when provided"),
270+
] = None
271+
params: Annotated[
272+
dict[str, Any] | str | None,
273+
Query(description="Filter by params using JSON containment"),
274+
Doc("Filter by params using JSON containment when provided"),
249275
] = None
276+
277+
@model_validator(mode="before")
278+
@classmethod
279+
def _parse_params_str(cls, data: Any) -> Any:
280+
if isinstance(data, dict) and "params" in data:
281+
data = {**data}
282+
data["params"] = _parse_json_str(data["params"])
283+
return data
284+
250285
limit: Annotated[
251286
int | None,
252287
Query(description="Maximum number of results to return", ge=1),
288+
Doc("Maximum number of results to return when provided"),
253289
] = None
254290
cursor: Annotated[
255291
str,
256292
Query(description="Pagination cursor from a previous response"),
293+
Doc("Pagination cursor from a previous response when provided"),
257294
] = ""
258295

259296
_filter_map: ClassVar[dict[str, str]] = {
@@ -278,7 +315,7 @@ def filters(self) -> dict:
278315
)
279316
async def get_history(
280317
db_plugin: TaskDbPluginDep,
281-
q: Annotated[HistoryQuery, Depends()],
318+
q: Annotated[TaskHistoryQuery, Query()],
282319
) -> TaskRunHistoryPage:
283320
return await db_plugin.get_history(q)
284321

tests/scheduler/test_db_plugin.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
from datetime import datetime, timezone
34
from typing import Any, AsyncIterator, cast
45

@@ -10,7 +11,12 @@
1011
from examples import tasks
1112
from fluid.scheduler import TaskState
1213
from fluid.scheduler.consumer import TaskConsumer
13-
from fluid.scheduler.db import TaskDbPlugin, get_db_plugin, with_task_history_router
14+
from fluid.scheduler.db import (
15+
TaskDbPlugin,
16+
TaskHistoryQuery,
17+
get_db_plugin,
18+
with_task_history_router,
19+
)
1420
from fluid.scheduler.endpoints import get_task_manager, task_manager_fastapi
1521
from fluid.utils.http_client import HttpResponseError
1622
from tests.scheduler.tasks import TaskClient, redis_broker, start_fastapi
@@ -268,3 +274,44 @@ async def test_get_history_filter_by_end(
268274
assert any(item["id"] == task_run.id for item in data)
269275
data_empty = await get_history(cli_db, end="2000-01-01T00:00:00Z")
270276
assert data_empty == []
277+
278+
279+
async def test_get_history_filter_by_params_programmatic(
280+
task_manager_db: TaskConsumer, db_plugin: TaskDbPlugin
281+
) -> None:
282+
task_run = await task_manager_db.queue_and_wait("add", timeout=5, a=7.0, b=8.0)
283+
assert task_run.state == TaskState.success
284+
285+
await wait_for_task_run(db_plugin, task_run.id)
286+
page = await db_plugin.get_history(TaskHistoryQuery(params={"a": 7.0}))
287+
assert len(page.data) >= 1
288+
assert any(r.id == task_run.id for r in page.data)
289+
assert all(7.0 == r.params.get("a") for r in page.data)
290+
291+
# Negative: filter that shouldn't match
292+
page_empty = await db_plugin.get_history(TaskHistoryQuery(params={"a": 999.0}))
293+
assert not any(r.id == task_run.id for r in page_empty.data)
294+
295+
296+
async def test_get_history_filter_by_params_http(
297+
cli_db: TaskClient, task_manager_db: TaskConsumer, db_plugin: TaskDbPlugin
298+
) -> None:
299+
task_run = await task_manager_db.queue_and_wait("add", timeout=5, a=9.0, b=10.0)
300+
assert task_run.state == TaskState.success
301+
302+
await wait_for_task_run(db_plugin, task_run.id)
303+
304+
data = await get_history(cli_db, params=json.dumps({"a": 9.0}))
305+
assert any(item["id"] == task_run.id for item in data)
306+
assert all(9.0 == item["params"].get("a") for item in data)
307+
308+
309+
async def test_get_history_filter_by_params_http_negative(
310+
cli_db: TaskClient, task_manager_db: TaskConsumer, db_plugin: TaskDbPlugin
311+
) -> None:
312+
task_run = await task_manager_db.queue_and_wait("add", timeout=5, a=11.0, b=12.0)
313+
assert task_run.state == TaskState.success
314+
315+
await wait_for_task_run(db_plugin, task_run.id)
316+
data_empty = await get_history(cli_db, params=json.dumps({"a": 999.0}))
317+
assert not any(item["id"] == task_run.id for item in data_empty)

0 commit comments

Comments
 (0)