Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions app/pages/tasks/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ if (!route.query.perPage) {
})
}

const searchRef = ref('')
const searchRef = ref(route.query.search?.toString() ?? '')
const argsSearchRef = ref(route.query.argsSearch?.toString() ?? '')
const refreshSeconds = ref(5)
const refreshActivated = ref(true)
const queryParams = reactive<QueryParams>({
perPage: Number(route.query.perPage) || perPageStorage.value,
state: route.query.state?.toString(),
search: route.query.search?.toString(),
argsSearch: route.query.argsSearch?.toString(),
startDate: route.query.startDate?.toString(),
endDate: route.query.endDate?.toString(),
cursorQueuedAt: route.query.cursorQueuedAt?.toString(),
Expand All @@ -49,6 +51,7 @@ watch(
queryParams.perPage = Number(route.query.perPage) || perPageStorage.value
queryParams.state = route.query.state?.toString()
queryParams.search = route.query.search?.toString()
queryParams.argsSearch = route.query.argsSearch?.toString()
queryParams.startDate = route.query.startDate?.toString()
queryParams.endDate = route.query.endDate?.toString()
queryParams.cursorQueuedAt = route.query.cursorQueuedAt?.toString()
Expand Down Expand Up @@ -95,6 +98,7 @@ const { data, refresh } = useAsyncData<{
limit: queryParams.perPage,
state: queryParams.state,
search: queryParams.search,
argsSearch: queryParams.argsSearch,
startDate: queryParams.startDate,
endDate: queryParams.endDate,
cursorQueuedAt: queryParams.cursorQueuedAt,
Expand All @@ -111,6 +115,7 @@ const filtersExist = computed(
() =>
queryParams.state ||
queryParams.search ||
queryParams.argsSearch ||
queryParams.startDate ||
queryParams.endDate
)
Expand Down Expand Up @@ -157,13 +162,14 @@ useIntervalFn(() => {
}, refreshMilliseconds)

const searchSubmit = () => {
if (searchRef.value) {
resetCursor()
queryParams.search = searchRef.value
}
resetCursor()
queryParams.search = searchRef.value || undefined
queryParams.argsSearch = argsSearchRef.value || undefined
}

const clearFilters = () => {
searchRef.value = ''
argsSearchRef.value = ''
router.push({
path: '/tasks',
query: {
Expand Down Expand Up @@ -262,17 +268,26 @@ const handlePrev = () => goTo(data.value?.prevCursor, 'prev')
>Clear Filters</Button
>
</div>
<div class="flex">
<div class="flex gap-2">
<Input
type="search"
name="search"
class="form-control w-auto"
placeholder="Search tasks..."
v-model="searchRef"
@keyup.enter="searchSubmit"
/>
<Input
type="search"
name="argsSearch"
class="form-control w-auto"
placeholder="Search args / kwargs..."
v-model="argsSearchRef"
@keyup.enter="searchSubmit"
/>
<Button
@click="searchSubmit"
class="btn btn-primary ml-2"
class="btn btn-primary"
>
Search
</Button>
Expand Down
1 change: 1 addition & 0 deletions server/api/tasks/index.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {

return tasksRepository.getAll({
name: query.search ? query.search : null,
args: query.argsSearch ? query.argsSearch : null,
limit: query.limit,
state: query.state,
startDate: query.startDate,
Expand Down
18 changes: 18 additions & 0 deletions server/repositories/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,23 @@ const buildNameCondition = (name: string) => {
)} WHERE name MATCH ${ftsQuery})`
}

// substring search over serialized args and kwargs
const buildArgsCondition = (value: string) => {
if (envVariables.dbDriver === 'postgres') {
const pattern = `%${escapeLikePattern(value.toLowerCase())}%`
// must stay in sync with the tasksArgsTrgm index expression in db/init.ts
return sql`lower(coalesce(${tasksTable.args}::text, '') || ' ' || coalesce(${tasksTable.kwargs}::text, '')) LIKE ${pattern}`
}
// sqlite LIKE is case-insensitive for ASCII only and lower() is ASCII-only too,
// so the pattern is passed as is
const pattern = `%${escapeLikePattern(value)}%`
return sql`(coalesce(${tasksTable.args}, '') || ' ' || coalesce(${tasksTable.kwargs}, '')) LIKE ${pattern} ESCAPE '\\'`
}

class TasksRepository {
async getAll({
name,
args,
state,
limit,
cursor,
Expand All @@ -38,6 +52,7 @@ class TasksRepository {
}: {
limit: number
name: string | null
args?: string | null
state?: TaskState
cursor?: { queuedAt: Date; id: string }
direction?: 'next' | 'prev'
Expand All @@ -48,6 +63,9 @@ class TasksRepository {
if (name) {
whereConditions.push(buildNameCondition(name))
}
if (args) {
whereConditions.push(buildArgsCondition(args))
}
if (state) {
whereConditions.push(eq(tasksTable.state, state))
}
Expand Down
3 changes: 2 additions & 1 deletion shared/db/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const DB_INDEX_NAMES = {
tasksStateStartedAt: 'idx_taskiq_admin_tasks__state_started_at',
tasksStateExecutionTime: 'idx_taskiq_admin_tasks__state_execution_time',
tasksNameFts: 'idx_taskiq_admin_tasks__name_fts',
tasksNameTrgm: 'idx_taskiq_admin_tasks__name_trgm'
tasksNameTrgm: 'idx_taskiq_admin_tasks__name_trgm',
tasksArgsTrgm: 'idx_taskiq_admin_tasks__args_trgm'
} as const

export const DB_TRIGGER_NAMES = {
Expand Down
5 changes: 5 additions & 0 deletions shared/db/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export const dbInitializationStatements: Record<DBDriver, DBInitStatement[]> = {
sql: `CREATE INDEX IF NOT EXISTS "${DB_INDEX_NAMES.tasksNameTrgm}" ON "${DB_TABLE_NAMES.tasks}" USING gin (lower("name") gin_trgm_ops)`,
optional: true,
warning: PG_TRGM_WARNING
},
{
sql: `CREATE INDEX IF NOT EXISTS "${DB_INDEX_NAMES.tasksArgsTrgm}" ON "${DB_TABLE_NAMES.tasks}" USING gin ((lower(coalesce("args"::text, '') || ' ' || coalesce("kwargs"::text, ''))) gin_trgm_ops)`,
optional: true,
warning: PG_TRGM_WARNING
}
]
}
1 change: 1 addition & 0 deletions shared/schemas/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const taskRouteParamsSchema = z.object({
export const getTasksQueryParamsSchema = z
.object({
search: z.string().optional(),
argsSearch: z.string().optional(),
limit: z.coerce.number().int().gte(1).lte(100),
state: z
.enum(['queued', 'success', 'running', 'failure', 'abandoned'])
Expand Down
1 change: 1 addition & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type QueryParams = {
perPage: number
state?: string
search?: string
argsSearch?: string
startDate?: string
endDate?: string
cursorQueuedAt?: string
Expand Down
68 changes: 67 additions & 1 deletion tests/helpers/search-suite.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { $fetch } from '@nuxt/test-utils'
import { beforeAll, describe, expect, test } from 'vitest'
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
import { SEARCH_CASES, SEARCH_TASK_NAMES } from './search-fixtures'

const token = { 'access-token': 'supersecret' }
Expand Down Expand Up @@ -88,5 +88,71 @@ export const registerSearchTests = (driver: 'sqlite' | 'postgres') => {
fetchTasks({ cursorId: 'x' })
).rejects.toMatchObject({ statusCode: 400 })
})

describe(`task args search (${driver})`, () => {
const queue = (
id: string,
args: unknown[],
kwargs: Record<string, unknown>
) =>
$fetch(`/api/tasks/${id}/queued`, {
method: 'POST',
headers: token,
body: {
taskName: 'args.task',
args,
kwargs,
worker: 'w',
queuedAt: new Date('2025-01-02T10:00:00Z').toISOString()
}
})

// seeded lazily: @nuxt/test-utils drops its context after each test,
// so a nested beforeAll would run without it
let seeded = false
beforeEach(async () => {
if (seeded) return
seeded = true
await queue('args-0', ['000123', 42], {})
await queue('args-1', [], { customer_id: '000777', lang: 'KG' })
await queue('args-2', ['100%_done'], { note: 'Привет' })
})

const ids = async (query: Record<string, string>) =>
(await fetchTasks(query)).tasks.map((task) => task.id).sort()

test('matches a value in args', async () => {
expect(await ids({ argsSearch: '000123' })).toEqual(['args-0'])
})

test('matches a value in kwargs', async () => {
expect(await ids({ argsSearch: '000777' })).toEqual(['args-1'])
})

test('matches a kwargs key', async () => {
expect(await ids({ argsSearch: 'customer_id' })).toEqual(['args-1'])
})

test('is case insensitive', async () => {
expect(await ids({ argsSearch: 'kg' })).toEqual(['args-1'])
})

test('matches non-ascii values', async () => {
expect(await ids({ argsSearch: 'Привет' })).toEqual(['args-2'])
})

test('treats % and _ as literal characters', async () => {
expect(await ids({ argsSearch: '0%_d' })).toEqual(['args-2'])
expect(await ids({ argsSearch: '1%done' })).toEqual([])
})

test('combines with name search', async () => {
expect(await ids({ argsSearch: '000', search: 'args.task' })).toEqual([
'args-0',
'args-1'
])
expect(await ids({ argsSearch: '000', search: 'send' })).toEqual([])
})
})
})
}