@fabrika/platform-node 0.4.7 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/platform-node",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,6 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@fabrika/platform": "0.4.7"
40
+ "@fabrika/platform": "0.5.0"
41
41
  }
42
42
  }
@@ -0,0 +1,62 @@
1
+ import { SQL } from 'bun'
2
+
3
+ /**
4
+ * SQLSTATE 53300 (`too_many_connections`): no slot the role may take is free, whether the server's, the database's or
5
+ * the role's limit is reached. It refuses a connection and never a statement on one that is open.
6
+ */
7
+ export const TOO_MANY_CONNECTIONS = '53300'
8
+
9
+ /** How every report of an exhausted connection limit starts, so a caller that reads only output can recognise one. */
10
+ export const CONNECTION_CAPACITY_MESSAGE = 'the database had no free connection slot'
11
+
12
+ /** How long a caller waits for a free connection slot by default before it gives up. */
13
+ export const DEFAULT_CONNECTION_WAIT_MS = 60_000
14
+
15
+ const FIRST_DELAY_MS = 250
16
+ const MAX_DELAY_MS = 5_000
17
+
18
+ /** Whether `error` is the server refusing a connection because every slot it may take is in use. */
19
+ export function isConnectionCapacityError(error: unknown): boolean {
20
+ return error instanceof SQL.PostgresError && error.errno === TOO_MANY_CONNECTIONS
21
+ }
22
+
23
+ /** The database kept refusing connections for lack of a free slot until the wait ran out. */
24
+ export class ConnectionCapacityError extends Error {
25
+ constructor(readonly waitedMs: number) {
26
+ super(`${CONNECTION_CAPACITY_MESSAGE} for ${Math.round(waitedMs / 1000)} s (SQLSTATE ${TOO_MANY_CONNECTIONS}): its connection limit is exhausted`)
27
+ this.name = 'ConnectionCapacityError'
28
+ }
29
+ }
30
+
31
+ export interface ConnectionCapacityWait {
32
+ /** How long to keep retrying; `DEFAULT_CONNECTION_WAIT_MS` by default. */
33
+ readonly waitMs?: number
34
+ readonly sleep?: (ms: number) => Promise<void>
35
+ readonly now?: () => number
36
+ readonly random?: () => number
37
+ }
38
+
39
+ /**
40
+ * Runs `attempt` until it does not fail for lack of a connection slot, waiting with jittered exponential backoff between
41
+ * tries, and throws `ConnectionCapacityError` once `waitMs` has passed. Any other failure is thrown at once. `attempt`
42
+ * must release whatever it opened before it throws, so that a waiting caller holds no slot another needs.
43
+ */
44
+ export async function withConnectionCapacity<T>(attempt: () => Promise<T>, options: ConnectionCapacityWait = {}): Promise<T> {
45
+ const waitMs = options.waitMs ?? DEFAULT_CONNECTION_WAIT_MS
46
+ const sleep = options.sleep ?? Bun.sleep
47
+ const now = options.now ?? Date.now
48
+ const random = options.random ?? Math.random
49
+ const started = now()
50
+ for (let tries = 0;; tries++) {
51
+ try {
52
+ return await attempt()
53
+ } catch (error) {
54
+ if (!isConnectionCapacityError(error)) throw error
55
+ const left = started + waitMs - now()
56
+ if (left <= 0) throw new ConnectionCapacityError(now() - started)
57
+ // Jittered, so the containers of one deploy that were refused together do not retry together.
58
+ const delay = Math.min(MAX_DELAY_MS, FIRST_DELAY_MS * 2 ** tries) * (0.5 + random() / 2)
59
+ await sleep(Math.min(left, delay))
60
+ }
61
+ }
62
+ }
package/src/index.ts CHANGED
@@ -35,6 +35,15 @@ export type { ApplicationDatabaseRoles } from './application-postgres-roles'
35
35
  export { applicationDatabaseSchema } from './application-schema'
36
36
  export { FileSystemAssetServer, type FileSystemAssetServerOptions } from './asset-server-fs'
37
37
  export { S3BlobStore, type S3BlobStoreOptions } from './blob-s3'
38
+ export {
39
+ CONNECTION_CAPACITY_MESSAGE,
40
+ ConnectionCapacityError,
41
+ type ConnectionCapacityWait,
42
+ DEFAULT_CONNECTION_WAIT_MS,
43
+ isConnectionCapacityError,
44
+ TOO_MANY_CONNECTIONS,
45
+ withConnectionCapacity,
46
+ } from './connection-capacity'
38
47
  export { databaseCodeQuery } from './database-code'
39
48
  export {
40
49
  decodeJob,