@open-mercato/search 0.6.6-develop.5594.1.30cd738303 → 0.6.6-develop.5612.1.d382eb2f33
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.
|
@@ -20,14 +20,23 @@ test.describe("TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock
|
|
|
20
20
|
const settings = await apiRequest(request, "GET", "/api/search/settings", { token });
|
|
21
21
|
expect(settings.ok(), "search settings should be readable after starting reindex").toBeTruthy();
|
|
22
22
|
const settingsBody = await readJsonSafe(settings) ?? {};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
const heldLock = settingsBody.settings?.fulltextReindexLock ?? null;
|
|
24
|
+
if (heldLock) {
|
|
25
|
+
expect(heldLock.type, "a held reindex lock identifies the fulltext lock").toBe("fulltext");
|
|
26
|
+
const second = await apiRequest(request, "POST", "/api/search/reindex", { token, data: { useQueue: true } });
|
|
27
|
+
expect(second.status(), "a concurrent reindex must be rejected with 409").toBe(409);
|
|
28
|
+
const body = await readJsonSafe(second) ?? {};
|
|
29
|
+
expect(body.lock?.type, "the 409 lock descriptor identifies the fulltext lock").toBe("fulltext");
|
|
30
|
+
expect(typeof body.lock?.action, "the lock reports its action").toBe("string");
|
|
31
|
+
expect(typeof body.lock?.startedAt, "the lock reports when it started").toBe("string");
|
|
32
|
+
expect(typeof body.lock?.elapsedMinutes, "the lock reports elapsed minutes").toBe("number");
|
|
33
|
+
} else {
|
|
34
|
+
const second = await apiRequest(request, "POST", "/api/search/reindex", { token, data: { useQueue: true } });
|
|
35
|
+
expect(
|
|
36
|
+
[200, 503],
|
|
37
|
+
"with no held lock a concurrent reindex is not rejected with 409"
|
|
38
|
+
).toContain(second.status());
|
|
39
|
+
}
|
|
31
40
|
} finally {
|
|
32
41
|
if (token) {
|
|
33
42
|
await apiRequest(request, "POST", "/api/search/reindex/cancel", { token }).catch(() => void 0);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-007.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\ntype ReindexLock = {\n type?: string\n action?: string\n startedAt?: string\n elapsedMinutes?: number\n processedCount?: number | null\n totalCount?: number | null\n}\ntype ConflictBody = { error?: string; lock?: ReindexLock }\ntype SearchSettingsResponse = { settings?: { fulltextReindexLock?: ReindexLock | null } }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\n\n/**\n * TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info.\n * Source: issue #2483.\n *\n * Route: POST /api/search/reindex (requireFeatures ['search.reindex']). The route\n * acquires a per-tenant DB lock
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAa7B,MAAM,qBAAqB,CAAC,YAAY,UAAU,QAAQ;
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\ntype ReindexLock = {\n type?: string\n action?: string\n startedAt?: string\n elapsedMinutes?: number\n processedCount?: number | null\n totalCount?: number | null\n}\ntype ConflictBody = { error?: string; lock?: ReindexLock }\ntype SearchSettingsResponse = { settings?: { fulltextReindexLock?: ReindexLock | null } }\n\nconst DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']\n\n/**\n * TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info.\n * Source: issue #2483.\n *\n * Route: POST /api/search/reindex (requireFeatures ['search.reindex']). The route\n * acquires a per-tenant DB lock before starting the operation. In queue mode\n * (useQueue:true \u2014 the default) it only KEEPS the lock when there is queued work\n * for workers to own \u2014 i.e. the fulltext backend is available AND at least one\n * indexing job was enqueued. On the no-op paths (no indexable strategy / backend\n * unavailable \u2192 503, or zero jobs enqueued) the route deliberately releases the\n * lock in `finally` so it cannot get stuck with no worker to clear it (the\n * unit-level contract is pinned by `reindex.routes.test.ts`).\n *\n * Consequently the 409 \"already in progress\" guarantee is only observable while a\n * real reindex is actively holding the lock. This integration environment has no\n * Meilisearch backend, so the first reindex returns 503 and holds no lock; the\n * test asserts the held-lock \u2192 409 contract only when a lock is genuinely present,\n * and otherwise asserts that a backend-unavailable reindex neither leaves a lock\n * nor spuriously rejects a concurrent call. Integration specs run serially\n * (workers:1), so no other test contends for the shared lock; it is released in\n * `finally` via the cancel endpoint.\n */\ntest.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info', () => {\n test('a second reindex while one is active is rejected with 409', async ({ request }) => {\n test.slow()\n test.setTimeout(120_000)\n\n let token: string | null = null\n\n try {\n token = await getAuthToken(request, 'admin')\n\n // Start from a clean lock state.\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)\n await apiRequest(request, 'POST', '/api/search/settings/global-search', {\n token,\n data: { enabledStrategies: DEFAULT_STRATEGIES },\n }).catch(() => undefined)\n\n // First reindex acquires the lock. It keeps the lock only when there is\n // queued work (backend available + jobs enqueued); otherwise it returns\n // 503/200 and releases the lock.\n const first = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(first.status(), 'first reindex must not hit a pre-existing lock').not.toBe(409)\n expect([200, 503], 'first reindex starts (200) or reports backend unavailable (503)').toContain(first.status())\n\n const settings = await apiRequest(request, 'GET', '/api/search/settings', { token })\n expect(settings.ok(), 'search settings should be readable after starting reindex').toBeTruthy()\n const settingsBody = (await readJsonSafe<SearchSettingsResponse>(settings)) ?? {}\n const heldLock = settingsBody.settings?.fulltextReindexLock ?? null\n\n if (heldLock) {\n // A real reindex is holding the lock: the concurrency contract applies.\n expect(heldLock.type, 'a held reindex lock identifies the fulltext lock').toBe('fulltext')\n\n const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(second.status(), 'a concurrent reindex must be rejected with 409').toBe(409)\n const body = (await readJsonSafe<ConflictBody>(second)) ?? {}\n expect(body.lock?.type, 'the 409 lock descriptor identifies the fulltext lock').toBe('fulltext')\n expect(typeof body.lock?.action, 'the lock reports its action').toBe('string')\n expect(typeof body.lock?.startedAt, 'the lock reports when it started').toBe('string')\n expect(typeof body.lock?.elapsedMinutes, 'the lock reports elapsed minutes').toBe('number')\n } else {\n // No backend / no queued work: the route released the lock, so a\n // concurrent reindex is NOT rejected with 409 \u2014 it behaves like the first.\n const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })\n expect(\n [200, 503],\n 'with no held lock a concurrent reindex is not rejected with 409',\n ).toContain(second.status())\n }\n } finally {\n if (token) {\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)\n }\n }\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAa7B,MAAM,qBAAqB,CAAC,YAAY,UAAU,QAAQ;AAwB1D,KAAK,SAAS,yEAAyE,MAAM;AAC3F,OAAK,6DAA6D,OAAO,EAAE,QAAQ,MAAM;AACvF,SAAK,KAAK;AACV,SAAK,WAAW,IAAO;AAEvB,QAAI,QAAuB;AAE3B,QAAI;AACF,cAAQ,MAAM,aAAa,SAAS,OAAO;AAG3C,YAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAChG,YAAM,WAAW,SAAS,QAAQ,sCAAsC;AAAA,QACtE;AAAA,QACA,MAAM,EAAE,mBAAmB,mBAAmB;AAAA,MAChD,CAAC,EAAE,MAAM,MAAM,MAAS;AAKxB,YAAM,QAAQ,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC1G,aAAO,MAAM,OAAO,GAAG,gDAAgD,EAAE,IAAI,KAAK,GAAG;AACrF,aAAO,CAAC,KAAK,GAAG,GAAG,iEAAiE,EAAE,UAAU,MAAM,OAAO,CAAC;AAE9G,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,aAAO,SAAS,GAAG,GAAG,2DAA2D,EAAE,WAAW;AAC9F,YAAM,eAAgB,MAAM,aAAqC,QAAQ,KAAM,CAAC;AAChF,YAAM,WAAW,aAAa,UAAU,uBAAuB;AAE/D,UAAI,UAAU;AAEZ,eAAO,SAAS,MAAM,kDAAkD,EAAE,KAAK,UAAU;AAEzF,cAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC3G,eAAO,OAAO,OAAO,GAAG,gDAAgD,EAAE,KAAK,GAAG;AAClF,cAAM,OAAQ,MAAM,aAA2B,MAAM,KAAM,CAAC;AAC5D,eAAO,KAAK,MAAM,MAAM,sDAAsD,EAAE,KAAK,UAAU;AAC/F,eAAO,OAAO,KAAK,MAAM,QAAQ,6BAA6B,EAAE,KAAK,QAAQ;AAC7E,eAAO,OAAO,KAAK,MAAM,WAAW,kCAAkC,EAAE,KAAK,QAAQ;AACrF,eAAO,OAAO,KAAK,MAAM,gBAAgB,kCAAkC,EAAE,KAAK,QAAQ;AAAA,MAC5F,OAAO;AAGL,cAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,MAAM,EAAE,UAAU,KAAK,EAAE,CAAC;AAC3G;AAAA,UACE,CAAC,KAAK,GAAG;AAAA,UACT;AAAA,QACF,EAAE,UAAU,OAAO,OAAO,CAAC;AAAA,MAC7B;AAAA,IACF,UAAE;AACA,UAAI,OAAO;AACT,cAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,MAAM,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,MAClG;AAAA,IACF;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5612.1.d382eb2f33",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -126,9 +126,9 @@
|
|
|
126
126
|
"zod": "^4.4.3"
|
|
127
127
|
},
|
|
128
128
|
"peerDependencies": {
|
|
129
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
130
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
131
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
129
|
+
"@open-mercato/core": "0.6.6-develop.5612.1.d382eb2f33",
|
|
130
|
+
"@open-mercato/queue": "0.6.6-develop.5612.1.d382eb2f33",
|
|
131
|
+
"@open-mercato/shared": "0.6.6-develop.5612.1.d382eb2f33"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@types/jest": "^30.0.0",
|
|
@@ -20,13 +20,22 @@ const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
|
|
|
20
20
|
* Source: issue #2483.
|
|
21
21
|
*
|
|
22
22
|
* Route: POST /api/search/reindex (requireFeatures ['search.reindex']). The route
|
|
23
|
-
* acquires a per-tenant DB lock
|
|
24
|
-
* (useQueue:true — the default)
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* acquires a per-tenant DB lock before starting the operation. In queue mode
|
|
24
|
+
* (useQueue:true — the default) it only KEEPS the lock when there is queued work
|
|
25
|
+
* for workers to own — i.e. the fulltext backend is available AND at least one
|
|
26
|
+
* indexing job was enqueued. On the no-op paths (no indexable strategy / backend
|
|
27
|
+
* unavailable → 503, or zero jobs enqueued) the route deliberately releases the
|
|
28
|
+
* lock in `finally` so it cannot get stuck with no worker to clear it (the
|
|
29
|
+
* unit-level contract is pinned by `reindex.routes.test.ts`).
|
|
30
|
+
*
|
|
31
|
+
* Consequently the 409 "already in progress" guarantee is only observable while a
|
|
32
|
+
* real reindex is actively holding the lock. This integration environment has no
|
|
33
|
+
* Meilisearch backend, so the first reindex returns 503 and holds no lock; the
|
|
34
|
+
* test asserts the held-lock → 409 contract only when a lock is genuinely present,
|
|
35
|
+
* and otherwise asserts that a backend-unavailable reindex neither leaves a lock
|
|
36
|
+
* nor spuriously rejects a concurrent call. Integration specs run serially
|
|
37
|
+
* (workers:1), so no other test contends for the shared lock; it is released in
|
|
38
|
+
* `finally` via the cancel endpoint.
|
|
30
39
|
*/
|
|
31
40
|
test.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock info', () => {
|
|
32
41
|
test('a second reindex while one is active is rejected with 409', async ({ request }) => {
|
|
@@ -45,7 +54,9 @@ test.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock
|
|
|
45
54
|
data: { enabledStrategies: DEFAULT_STRATEGIES },
|
|
46
55
|
}).catch(() => undefined)
|
|
47
56
|
|
|
48
|
-
// First reindex acquires
|
|
57
|
+
// First reindex acquires the lock. It keeps the lock only when there is
|
|
58
|
+
// queued work (backend available + jobs enqueued); otherwise it returns
|
|
59
|
+
// 503/200 and releases the lock.
|
|
49
60
|
const first = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
|
|
50
61
|
expect(first.status(), 'first reindex must not hit a pre-existing lock').not.toBe(409)
|
|
51
62
|
expect([200, 503], 'first reindex starts (200) or reports backend unavailable (503)').toContain(first.status())
|
|
@@ -53,16 +64,28 @@ test.describe('TC-SEARCH-007: concurrent fulltext reindex returns 409 with lock
|
|
|
53
64
|
const settings = await apiRequest(request, 'GET', '/api/search/settings', { token })
|
|
54
65
|
expect(settings.ok(), 'search settings should be readable after starting reindex').toBeTruthy()
|
|
55
66
|
const settingsBody = (await readJsonSafe<SearchSettingsResponse>(settings)) ?? {}
|
|
56
|
-
|
|
67
|
+
const heldLock = settingsBody.settings?.fulltextReindexLock ?? null
|
|
68
|
+
|
|
69
|
+
if (heldLock) {
|
|
70
|
+
// A real reindex is holding the lock: the concurrency contract applies.
|
|
71
|
+
expect(heldLock.type, 'a held reindex lock identifies the fulltext lock').toBe('fulltext')
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
|
|
74
|
+
expect(second.status(), 'a concurrent reindex must be rejected with 409').toBe(409)
|
|
75
|
+
const body = (await readJsonSafe<ConflictBody>(second)) ?? {}
|
|
76
|
+
expect(body.lock?.type, 'the 409 lock descriptor identifies the fulltext lock').toBe('fulltext')
|
|
77
|
+
expect(typeof body.lock?.action, 'the lock reports its action').toBe('string')
|
|
78
|
+
expect(typeof body.lock?.startedAt, 'the lock reports when it started').toBe('string')
|
|
79
|
+
expect(typeof body.lock?.elapsedMinutes, 'the lock reports elapsed minutes').toBe('number')
|
|
80
|
+
} else {
|
|
81
|
+
// No backend / no queued work: the route released the lock, so a
|
|
82
|
+
// concurrent reindex is NOT rejected with 409 — it behaves like the first.
|
|
83
|
+
const second = await apiRequest(request, 'POST', '/api/search/reindex', { token, data: { useQueue: true } })
|
|
84
|
+
expect(
|
|
85
|
+
[200, 503],
|
|
86
|
+
'with no held lock a concurrent reindex is not rejected with 409',
|
|
87
|
+
).toContain(second.status())
|
|
88
|
+
}
|
|
66
89
|
} finally {
|
|
67
90
|
if (token) {
|
|
68
91
|
await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token }).catch(() => undefined)
|