@shivaedev/test-sql 0.12.0 → 0.13.0
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.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/server/raceWithLock.d.ts +1 -0
- package/dist/server/raceWithLock.d.ts.map +1 -1
- package/dist/server/raceWithLock.js +16 -5
- package/dist/server/raceWithLock.js.map +1 -1
- package/package.json +5 -5
- package/src/server/raceWithLock.ts +26 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `raceWithLock` takes `count: "every blocked session"` for contenders whose SQL runs on other fibers, such as an HTTP server's handlers. The holder then releases once it blocks as many sessions as there are effects.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `raceWithLock` releases its holder once its own contenders wait, instead of hanging until its timeout when another session also waits on the holder. Each contender's statements now start with a `/* raceWithLock <holder pid>.<index> */` comment.
|
|
12
|
+
|
|
3
13
|
## 0.12.0 - 2026-10-30
|
|
4
14
|
|
|
5
15
|
### Added
|
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ Inside an isolated block, `PostgresTestDatabase.url` names that clone. Use it to
|
|
|
156
156
|
|
|
157
157
|
### Race stories
|
|
158
158
|
|
|
159
|
-
Use `raceWithLock` with a live story clock inside an isolated database, outside a wrapping transaction. Supply the native SQL Effect that takes a row, table or transaction advisory lock, and one Effect per competing call. The helper holds the lock in a separate transaction, starts the calls, and waits
|
|
159
|
+
Use `raceWithLock` with a live story clock inside an isolated database, outside a wrapping transaction. Supply the native SQL Effect that takes a row, table or transaction advisory lock, and one Effect per competing call. The helper holds the lock in a separate transaction, starts the calls, and waits until PostgreSQL's blocker graph contains every contender before releasing the holder. Each contender's statements start with a `/* raceWithLock <holder pid>.<index> */` comment, so the helper counts only its own contenders and another session waiting on the holder does not hold up the release. When the contenders' SQL runs on other fibers, such as an HTTP server's handlers, the tag cannot reach it; set `count: "every blocked session"` to release once the holder blocks as many sessions as there are effects, and keep other waiters out of that database. Other lock-taking work in that database can still queue ahead of the contenders and change who wins, and the helper needs at least `effects.length + 1` pool connections. Contenders keep their own transaction and service conventions.
|
|
160
160
|
|
|
161
161
|
```ts
|
|
162
162
|
import { raceWithLock } from "@shivaedev/test-sql/server/raceWithLock.ts";
|
|
@@ -5,5 +5,6 @@ export declare function raceWithLock<A, E, R, TLockError, TLockServices>(options
|
|
|
5
5
|
readonly effects: readonly Effect.Effect<A, E, R>[];
|
|
6
6
|
readonly timeout?: Duration.Input;
|
|
7
7
|
readonly release?: "commit" | "rollback";
|
|
8
|
+
readonly count?: "contenders" | "every blocked session";
|
|
8
9
|
}): Effect.Effect<import("effect/Result").Result<A, E>[], TLockError | import("effect/sql/SqlError").SqlError | import("effect/Cause").TimeoutError, import("effect/reactivity/Reactivity").Reactivity | SqlClient.SqlClient | Exclude<R, import("effect/Scope").Scope> | Exclude<Exclude<TLockServices, import("effect/Scope").Scope>, import("effect/Scope").Scope>>;
|
|
9
10
|
//# sourceMappingURL=raceWithLock.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"raceWithLock.d.ts","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAmB,MAAM,QAAQ,CAAC;AAC1E,OAAO,EAAE,SAAS,
|
|
1
|
+
{"version":3,"file":"raceWithLock.d.ts","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAmB,MAAM,QAAQ,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAa,MAAM,YAAY,CAAC;AAUlD,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,aAAa,EAAE,OAAO,EAAE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,aAAa,CAAC,CAAC;IACjE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IACzC,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,GAAG,uBAAuB,CAAC;CACxD,sWAyCA"}
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
import { Deferred, Effect, Fiber, Schedule } from "effect";
|
|
2
|
-
import { SqlClient } from "effect/sql";
|
|
2
|
+
import { SqlClient, Statement } from "effect/sql";
|
|
3
3
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
4
4
|
import { withTestTransaction } from "#withTestTransaction.ts";
|
|
5
|
+
function tagged(effect, tag) {
|
|
6
|
+
return Effect.provideService(effect, Statement.CurrentTransformer, (statement, sql) => Effect.succeed(sql `${sql.literal(`/* ${tag} */`)} ${statement}`));
|
|
7
|
+
}
|
|
5
8
|
export function raceWithLock(options) {
|
|
9
|
+
const everyBlockedSession = options.count === "every blocked session";
|
|
6
10
|
return Effect.scoped(Effect.gen(function* () {
|
|
7
11
|
const sql = yield* SqlClient.SqlClient;
|
|
8
12
|
const ready = yield* Deferred.make();
|
|
9
13
|
const started = yield* Deferred.make();
|
|
10
14
|
const hold = Effect.gen(function* () {
|
|
11
15
|
yield* options.lock;
|
|
12
|
-
yield*
|
|
16
|
+
const [holder] = yield* sql `select pg_backend_pid() as pid`;
|
|
17
|
+
const race = `raceWithLock ${holder?.pid}.`;
|
|
18
|
+
yield* Deferred.succeed(ready, race);
|
|
13
19
|
yield* Deferred.await(started);
|
|
20
|
+
// PostgreSQL signals nothing when a session starts waiting on a lock, so the holder polls its blocker graph.
|
|
14
21
|
yield* sql `select pg_stat_clear_snapshot()`.pipe(Effect.andThen(sql `with recursive blocked(pid) as (
|
|
15
22
|
select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
|
|
16
23
|
union
|
|
17
24
|
select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
|
|
18
|
-
) select count(*)
|
|
25
|
+
) select ${everyBlockedSession ? sql `count(*)` : sql `count(distinct split_part(activity.query, ' */', 1))`}::integer as waiting
|
|
26
|
+
from pg_stat_activity activity join blocked using (pid)
|
|
27
|
+
where ${everyBlockedSession ? sql `true` : sql `starts_with(activity.query, ${`/* ${race}`})`}`), Effect.repeat({ schedule: Schedule.spaced("10 millis"), while: ([row]) => (row?.waiting ?? 0) < options.effects.length }));
|
|
19
28
|
});
|
|
20
29
|
const holder = yield* (options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)).pipe(Effect.forkScoped);
|
|
21
|
-
yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
|
|
22
|
-
const contenders = yield* Effect.forEach(options.effects, Effect.result
|
|
30
|
+
const race = yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
|
|
31
|
+
const contenders = yield* Effect.forEach(options.effects, (effect, index) => Effect.result(everyBlockedSession ? effect : tagged(effect, `${race}${index}`)), {
|
|
32
|
+
concurrency: "unbounded",
|
|
33
|
+
}).pipe(Effect.forkScoped);
|
|
23
34
|
yield* Deferred.succeed(started, undefined);
|
|
24
35
|
yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
|
|
25
36
|
return yield* Fiber.join(contenders);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"raceWithLock.js","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"raceWithLock.js","sourceRoot":"","sources":["../../src/server/raceWithLock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAE9D,SAAS,MAAM,CAAU,MAA8B,EAAE,GAAW;IACnE,OAAO,MAAM,CAAC,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,kBAAkB,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,EAAE,CACrF,MAAM,CAAC,OAAO,CAAC,GAAG,CAAA,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,GAAG,KAAK,CAAC,IAAI,SAAS,EAAE,CAAC,CAChE,CAAC;AACH,CAAC;AAED,MAAM,UAAU,YAAY,CAAqC,OAMhE;IACA,MAAM,mBAAmB,GAAG,OAAO,CAAC,KAAK,KAAK,uBAAuB,CAAC;IACtE,OAAO,MAAM,CAAC,MAAM,CACnB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC;QACvC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAU,CAAC;QAC7C,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAQ,CAAC;QAC7C,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;YAChC,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;YACpB,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAiB,gCAAgC,CAAC;YAC7E,MAAM,IAAI,GAAG,gBAAgB,MAAM,EAAE,GAAG,GAAG,CAAC;YAC5C,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YACrC,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAC/B,6GAA6G;YAC7G,KAAK,CAAC,CAAC,GAAG,CAAA,iCAAiC,CAAC,IAAI,CAC/C,MAAM,CAAC,OAAO,CAAC,GAAG,CAAqB;;;;gBAI5B,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAA,UAAU,CAAC,CAAC,CAAC,GAAG,CAAA,sDAAsD;;aAElG,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAA,MAAM,CAAC,CAAC,CAAC,GAAG,CAAA,+BAA+B,MAAM,IAAI,EAAE,GAAG,EAAE,CAAC,EAC9F,MAAM,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,EAAE,OAAO,IAAI,CAAC,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CACzH,CAAC;QACH,CAAC,CAAC,CAAC;QACH,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CACrB,OAAO,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAC1H,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAC1B,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnH,MAAM,UAAU,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CACvC,OAAO,CAAC,OAAO,EACf,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,IAAI,GAAG,KAAK,EAAE,CAAC,CAAC,EAClG;YACC,WAAW,EAAE,WAAW;SACxB,CACD,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAC1B,KAAK,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;QAC5C,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACvG,OAAO,KAAK,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACtC,CAAC,CAAC,CACF,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,IAAI,WAAW,CAAC,CAAC,CAAC;AACxD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/test-sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "PostgreSQL rollback and real-commit test isolation for Effect",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -43,16 +43,16 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@shivaedev/sql": "0.26.
|
|
46
|
+
"@shivaedev/sql": "0.26.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@effect/sql-pg": "4.0.6",
|
|
50
50
|
"@effect/vitest": "4.0.6",
|
|
51
|
-
"@shivaedev/jobs": "0.23.
|
|
52
|
-
"@shivaedev/quality": "0.
|
|
51
|
+
"@shivaedev/jobs": "0.23.1",
|
|
52
|
+
"@shivaedev/quality": "0.47.0",
|
|
53
53
|
"@shivaedev/service": "0.6.0",
|
|
54
54
|
"@shivaedev/test": "0.11.0",
|
|
55
|
-
"@shivaedev/test-story": "0.25.
|
|
55
|
+
"@shivaedev/test-story": "0.25.1",
|
|
56
56
|
"@types/node": "24.19.2",
|
|
57
57
|
"effect": "4.0.6",
|
|
58
58
|
"pg-boss": "12.38.0",
|
|
@@ -1,37 +1,56 @@
|
|
|
1
1
|
import { Deferred, type Duration, Effect, Fiber, Schedule } from "effect";
|
|
2
|
-
import { SqlClient } from "effect/sql";
|
|
2
|
+
import { SqlClient, Statement } from "effect/sql";
|
|
3
3
|
import { transact } from "@shivaedev/sql/transact.ts";
|
|
4
4
|
import { withTestTransaction } from "#withTestTransaction.ts";
|
|
5
5
|
|
|
6
|
+
function tagged<A, E, R>(effect: Effect.Effect<A, E, R>, tag: string) {
|
|
7
|
+
return Effect.provideService(effect, Statement.CurrentTransformer, (statement, sql) =>
|
|
8
|
+
Effect.succeed(sql`${sql.literal(`/* ${tag} */`)} ${statement}`),
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export function raceWithLock<A, E, R, TLockError, TLockServices>(options: {
|
|
7
13
|
readonly lock: Effect.Effect<unknown, TLockError, TLockServices>;
|
|
8
14
|
readonly effects: readonly Effect.Effect<A, E, R>[];
|
|
9
15
|
readonly timeout?: Duration.Input;
|
|
10
16
|
readonly release?: "commit" | "rollback";
|
|
17
|
+
readonly count?: "contenders" | "every blocked session";
|
|
11
18
|
}) {
|
|
19
|
+
const everyBlockedSession = options.count === "every blocked session";
|
|
12
20
|
return Effect.scoped(
|
|
13
21
|
Effect.gen(function* () {
|
|
14
22
|
const sql = yield* SqlClient.SqlClient;
|
|
15
|
-
const ready = yield* Deferred.make<
|
|
23
|
+
const ready = yield* Deferred.make<string>();
|
|
16
24
|
const started = yield* Deferred.make<void>();
|
|
17
25
|
const hold = Effect.gen(function* () {
|
|
18
26
|
yield* options.lock;
|
|
19
|
-
yield*
|
|
27
|
+
const [holder] = yield* sql<{ pid: number }>`select pg_backend_pid() as pid`;
|
|
28
|
+
const race = `raceWithLock ${holder?.pid}.`;
|
|
29
|
+
yield* Deferred.succeed(ready, race);
|
|
20
30
|
yield* Deferred.await(started);
|
|
31
|
+
// PostgreSQL signals nothing when a session starts waiting on a lock, so the holder polls its blocker graph.
|
|
21
32
|
yield* sql`select pg_stat_clear_snapshot()`.pipe(
|
|
22
33
|
Effect.andThen(sql<{ waiting: number }>`with recursive blocked(pid) as (
|
|
23
34
|
select pid from pg_stat_activity where pg_backend_pid() = any(pg_blocking_pids(pid))
|
|
24
35
|
union
|
|
25
36
|
select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
|
|
26
|
-
) select count(*)::integer as waiting
|
|
27
|
-
|
|
37
|
+
) select ${everyBlockedSession ? sql`count(*)` : sql`count(distinct split_part(activity.query, ' */', 1))`}::integer as waiting
|
|
38
|
+
from pg_stat_activity activity join blocked using (pid)
|
|
39
|
+
where ${everyBlockedSession ? sql`true` : sql`starts_with(activity.query, ${`/* ${race}`})`}`),
|
|
40
|
+
Effect.repeat({ schedule: Schedule.spaced("10 millis"), while: ([row]) => (row?.waiting ?? 0) < options.effects.length }),
|
|
28
41
|
);
|
|
29
42
|
});
|
|
30
43
|
const holder = yield* (
|
|
31
44
|
options.release === "commit" ? transact(Effect.scoped(hold), { onSqlError: (error) => error }) : withTestTransaction(hold)
|
|
32
45
|
).pipe(Effect.forkScoped);
|
|
33
|
-
yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
|
|
34
|
-
const contenders = yield* Effect.forEach(
|
|
46
|
+
const race = yield* Effect.raceFirst(Deferred.await(ready), Fiber.join(holder).pipe(Effect.andThen(Effect.never)));
|
|
47
|
+
const contenders = yield* Effect.forEach(
|
|
48
|
+
options.effects,
|
|
49
|
+
(effect, index) => Effect.result(everyBlockedSession ? effect : tagged(effect, `${race}${index}`)),
|
|
50
|
+
{
|
|
51
|
+
concurrency: "unbounded",
|
|
52
|
+
},
|
|
53
|
+
).pipe(Effect.forkScoped);
|
|
35
54
|
yield* Deferred.succeed(started, undefined);
|
|
36
55
|
yield* Effect.raceFirst(Fiber.join(holder), Fiber.join(contenders).pipe(Effect.andThen(Effect.never)));
|
|
37
56
|
return yield* Fiber.join(contenders);
|