@pgpm/database-jobs 0.4.0 → 0.6.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/Makefile
CHANGED
package/README.md
CHANGED
|
@@ -2,4 +2,418 @@
|
|
|
2
2
|
|
|
3
3
|
Database-specific job handling and queue management.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@pgpm/database-jobs` provides a complete PostgreSQL-based background job processing system with persistent queues, scheduled jobs, and worker management. This package implements a robust job queue system entirely within PostgreSQL, enabling reliable background task processing with features like job locking, retries, priorities, and cron-style scheduling.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Persistent Job Queue**: Store jobs in PostgreSQL with ACID guarantees
|
|
12
|
+
- **Job Scheduling**: Cron-style and rule-based job scheduling
|
|
13
|
+
- **Worker Management**: Multiple workers with job locking and expiry
|
|
14
|
+
- **Priority Queue**: Process jobs by priority and run time
|
|
15
|
+
- **Automatic Retries**: Configurable retry attempts with exponential backoff
|
|
16
|
+
- **Job Keys**: Upsert semantics for idempotent job creation
|
|
17
|
+
- **Queue Management**: Named queues with independent locking
|
|
18
|
+
- **Notifications**: PostgreSQL LISTEN/NOTIFY for real-time job processing
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
If you have `pgpm` installed:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pgpm install @pgpm/database-jobs
|
|
26
|
+
pgpm deploy
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This is a quick way to get started. The sections below provide more detailed installation options.
|
|
30
|
+
|
|
31
|
+
### Prerequisites
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Install pgpm globally
|
|
35
|
+
npm install -g pgpm
|
|
36
|
+
|
|
37
|
+
# Start PostgreSQL
|
|
38
|
+
pgpm docker start
|
|
39
|
+
|
|
40
|
+
# Set environment variables
|
|
41
|
+
eval "$(pgpm env)"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Deploy
|
|
45
|
+
|
|
46
|
+
#### Option 1: Deploy by installing with pgpm
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pgpm install @pgpm/database-jobs
|
|
50
|
+
pgpm deploy
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#### Option 2: Deploy from Package Directory
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd packages/jobs/database-jobs
|
|
57
|
+
pgpm deploy --createdb
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
#### Option 3: Deploy from Workspace Root
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Install workspace dependencies
|
|
64
|
+
pnpm install
|
|
65
|
+
|
|
66
|
+
# Deploy with dependencies
|
|
67
|
+
pgpm deploy mydb1 --yes --createdb
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Core Concepts
|
|
71
|
+
|
|
72
|
+
### Jobs Table
|
|
73
|
+
|
|
74
|
+
The `app_jobs.jobs` table stores active jobs with the following key fields:
|
|
75
|
+
- `id`: Unique job identifier
|
|
76
|
+
- `database_id`: Database/tenant identifier
|
|
77
|
+
- `task_identifier`: Job type/handler name
|
|
78
|
+
- `payload`: JSON data for the job
|
|
79
|
+
- `priority`: Lower numbers = higher priority (default: 0)
|
|
80
|
+
- `run_at`: When the job should run
|
|
81
|
+
- `attempts`: Current attempt count
|
|
82
|
+
- `max_attempts`: Maximum retry attempts (default: 25)
|
|
83
|
+
- `locked_by`: Worker ID that locked this job
|
|
84
|
+
- `locked_at`: When the job was locked
|
|
85
|
+
- `key`: Optional unique key for upsert semantics
|
|
86
|
+
|
|
87
|
+
### Scheduled Jobs Table
|
|
88
|
+
|
|
89
|
+
The `app_jobs.scheduled_jobs` table stores recurring jobs with cron-style or rule-based scheduling.
|
|
90
|
+
|
|
91
|
+
### Job Queues Table
|
|
92
|
+
|
|
93
|
+
The `app_jobs.job_queues` table tracks queue statistics and locking state.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
### Adding Jobs
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
-- Add a simple job
|
|
101
|
+
SELECT app_jobs.add_job(
|
|
102
|
+
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
103
|
+
identifier := 'send_email',
|
|
104
|
+
payload := '{"to": "user@example.com", "subject": "Hello"}'::json
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
-- Add a job with priority and delayed execution
|
|
108
|
+
SELECT app_jobs.add_job(
|
|
109
|
+
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
110
|
+
identifier := 'generate_report',
|
|
111
|
+
payload := '{"report_id": 123}'::json,
|
|
112
|
+
run_at := now() + interval '1 hour',
|
|
113
|
+
priority := 10,
|
|
114
|
+
max_attempts := 5
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
-- Add a job with a unique key (upsert semantics)
|
|
118
|
+
SELECT app_jobs.add_job(
|
|
119
|
+
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
120
|
+
identifier := 'daily_summary',
|
|
121
|
+
payload := '{"date": "2025-01-15"}'::json,
|
|
122
|
+
job_key := 'daily_summary_2025_01_15',
|
|
123
|
+
queue_name := 'reports'
|
|
124
|
+
);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Getting Jobs (Worker Side)
|
|
128
|
+
|
|
129
|
+
```sql
|
|
130
|
+
-- Worker fetches next available job
|
|
131
|
+
SELECT * FROM app_jobs.get_job(
|
|
132
|
+
worker_id := 'worker-1',
|
|
133
|
+
task_identifiers := ARRAY['send_email', 'generate_report'],
|
|
134
|
+
job_expiry := interval '4 hours'
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
-- Returns NULL if no jobs available
|
|
138
|
+
-- Returns job row if job was successfully locked
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Completing Jobs
|
|
142
|
+
|
|
143
|
+
```sql
|
|
144
|
+
-- Mark job as complete
|
|
145
|
+
SELECT app_jobs.complete_job(
|
|
146
|
+
worker_id := 'worker-1',
|
|
147
|
+
job_id := 123
|
|
148
|
+
);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Failing Jobs
|
|
152
|
+
|
|
153
|
+
```sql
|
|
154
|
+
-- Mark job as failed (will retry if attempts < max_attempts)
|
|
155
|
+
SELECT app_jobs.fail_job(
|
|
156
|
+
worker_id := 'worker-1',
|
|
157
|
+
job_id := 123,
|
|
158
|
+
error_message := 'Connection timeout'
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Scheduled Jobs
|
|
163
|
+
|
|
164
|
+
```sql
|
|
165
|
+
-- Schedule a job with cron-style timing
|
|
166
|
+
INSERT INTO app_jobs.scheduled_jobs (
|
|
167
|
+
database_id,
|
|
168
|
+
task_identifier,
|
|
169
|
+
payload,
|
|
170
|
+
schedule_info
|
|
171
|
+
) VALUES (
|
|
172
|
+
'5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
173
|
+
'cleanup_old_data',
|
|
174
|
+
'{"days": 30}'::json,
|
|
175
|
+
'{
|
|
176
|
+
"hour": [2],
|
|
177
|
+
"minute": [0],
|
|
178
|
+
"dayOfWeek": [0, 1, 2, 3, 4, 5, 6]
|
|
179
|
+
}'::json
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
-- Schedule a job with a rule (every minute for 3 minutes)
|
|
183
|
+
SELECT app_jobs.add_scheduled_job(
|
|
184
|
+
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
185
|
+
identifier := 'heartbeat',
|
|
186
|
+
payload := '{}'::json,
|
|
187
|
+
schedule_info := json_build_object(
|
|
188
|
+
'start', now() + interval '10 seconds',
|
|
189
|
+
'end', now() + interval '3 minutes',
|
|
190
|
+
'rule', '*/1 * * * *'
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
-- Run a scheduled job (creates a job in the jobs table)
|
|
195
|
+
SELECT * FROM app_jobs.run_scheduled_job(scheduled_job_id := 1);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Functions Reference
|
|
199
|
+
|
|
200
|
+
### app_jobs.add_job(...)
|
|
201
|
+
|
|
202
|
+
Adds a new job to the queue or updates an existing job if a key is provided.
|
|
203
|
+
|
|
204
|
+
**Parameters:**
|
|
205
|
+
- `db_id` (uuid): Database/tenant identifier
|
|
206
|
+
- `identifier` (text): Job type/handler name
|
|
207
|
+
- `payload` (json): Job data (default: `{}`)
|
|
208
|
+
- `job_key` (text): Optional unique key for upsert (default: NULL)
|
|
209
|
+
- `queue_name` (text): Optional queue name (default: random UUID)
|
|
210
|
+
- `run_at` (timestamptz): When to run (default: now())
|
|
211
|
+
- `max_attempts` (integer): Maximum retries (default: 25)
|
|
212
|
+
- `priority` (integer): Job priority (default: 0)
|
|
213
|
+
|
|
214
|
+
**Returns:** `app_jobs.jobs` row
|
|
215
|
+
|
|
216
|
+
**Behavior:**
|
|
217
|
+
- If `job_key` is provided and exists, updates the job (if not locked)
|
|
218
|
+
- If job is locked, removes the key and creates a new job
|
|
219
|
+
- Triggers notifications for workers
|
|
220
|
+
|
|
221
|
+
### app_jobs.get_job(...)
|
|
222
|
+
|
|
223
|
+
Fetches and locks the next available job for a worker.
|
|
224
|
+
|
|
225
|
+
**Parameters:**
|
|
226
|
+
- `worker_id` (text): Unique worker identifier
|
|
227
|
+
- `task_identifiers` (text[]): Optional filter for job types (default: NULL = all)
|
|
228
|
+
- `job_expiry` (interval): How long before locked jobs expire (default: 4 hours)
|
|
229
|
+
|
|
230
|
+
**Returns:** `app_jobs.jobs` row or NULL
|
|
231
|
+
|
|
232
|
+
**Behavior:**
|
|
233
|
+
- Selects jobs by priority, run_at, and id
|
|
234
|
+
- Locks the job and its queue
|
|
235
|
+
- Increments attempt counter
|
|
236
|
+
- Uses `FOR UPDATE SKIP LOCKED` for concurrency
|
|
237
|
+
|
|
238
|
+
### app_jobs.complete_job(...)
|
|
239
|
+
|
|
240
|
+
Marks a job as successfully completed and removes it from the queue.
|
|
241
|
+
|
|
242
|
+
**Parameters:**
|
|
243
|
+
- `worker_id` (text): Worker that processed the job
|
|
244
|
+
- `job_id` (bigint): Job identifier
|
|
245
|
+
|
|
246
|
+
**Returns:** `app_jobs.jobs` row
|
|
247
|
+
|
|
248
|
+
### app_jobs.fail_job(...)
|
|
249
|
+
|
|
250
|
+
Marks a job as failed and schedules retry if attempts remain.
|
|
251
|
+
|
|
252
|
+
**Parameters:**
|
|
253
|
+
- `worker_id` (text): Worker that processed the job
|
|
254
|
+
- `job_id` (bigint): Job identifier
|
|
255
|
+
- `error_message` (text): Error description (default: NULL)
|
|
256
|
+
|
|
257
|
+
**Returns:** `app_jobs.jobs` row
|
|
258
|
+
|
|
259
|
+
**Behavior:**
|
|
260
|
+
- Records error message
|
|
261
|
+
- Unlocks the job for retry if attempts < max_attempts
|
|
262
|
+
- Permanently fails if max_attempts reached
|
|
263
|
+
|
|
264
|
+
### app_jobs.add_scheduled_job(...)
|
|
265
|
+
|
|
266
|
+
Creates a scheduled job with cron-style or rule-based timing.
|
|
267
|
+
|
|
268
|
+
**Parameters:**
|
|
269
|
+
- `db_id` (uuid): Database/tenant identifier
|
|
270
|
+
- `identifier` (text): Job type/handler name
|
|
271
|
+
- `payload` (json): Job data
|
|
272
|
+
- `schedule_info` (json): Scheduling configuration
|
|
273
|
+
- `job_key` (text): Optional unique key
|
|
274
|
+
- `queue_name` (text): Optional queue name
|
|
275
|
+
- `max_attempts` (integer): Maximum retries
|
|
276
|
+
- `priority` (integer): Job priority
|
|
277
|
+
|
|
278
|
+
**Returns:** `app_jobs.scheduled_jobs` row
|
|
279
|
+
|
|
280
|
+
### app_jobs.run_scheduled_job(...)
|
|
281
|
+
|
|
282
|
+
Executes a scheduled job by creating a job in the jobs table.
|
|
283
|
+
|
|
284
|
+
**Parameters:**
|
|
285
|
+
- `scheduled_job_id` (bigint): Scheduled job identifier
|
|
286
|
+
|
|
287
|
+
**Returns:** `app_jobs.jobs` row
|
|
288
|
+
|
|
289
|
+
## Job Processing Pattern
|
|
290
|
+
|
|
291
|
+
```sql
|
|
292
|
+
-- Worker loop (simplified)
|
|
293
|
+
LOOP
|
|
294
|
+
-- 1. Get next job
|
|
295
|
+
SELECT * FROM app_jobs.get_job('worker-1', ARRAY['my_task']);
|
|
296
|
+
|
|
297
|
+
-- 2. Process job
|
|
298
|
+
-- ... application logic ...
|
|
299
|
+
|
|
300
|
+
-- 3. Mark as complete or failed
|
|
301
|
+
IF success THEN
|
|
302
|
+
SELECT app_jobs.complete_job('worker-1', job_id);
|
|
303
|
+
ELSE
|
|
304
|
+
SELECT app_jobs.fail_job('worker-1', job_id, error_msg);
|
|
305
|
+
END IF;
|
|
306
|
+
END LOOP;
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Triggers and Automation
|
|
310
|
+
|
|
311
|
+
The package includes several triggers for automatic management:
|
|
312
|
+
|
|
313
|
+
- **timestamps**: Automatically sets created_at/updated_at
|
|
314
|
+
- **notify_worker**: Sends LISTEN/NOTIFY events when jobs are added
|
|
315
|
+
- **increase_job_queue_count**: Updates queue statistics on insert
|
|
316
|
+
- **decrease_job_queue_count**: Updates queue statistics on delete/update
|
|
317
|
+
|
|
318
|
+
## Dependencies
|
|
319
|
+
|
|
320
|
+
- `@pgpm/default-roles`: Role-based access control definitions
|
|
321
|
+
- `@pgpm/verify`: Verification utilities for database objects
|
|
322
|
+
|
|
323
|
+
## Testing
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
pnpm test
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The test suite validates:
|
|
330
|
+
- Job creation and retrieval
|
|
331
|
+
- Scheduled job creation with cron and rule-based timing
|
|
332
|
+
- Job key upsert semantics
|
|
333
|
+
- Worker locking and concurrency
|
|
334
|
+
|
|
335
|
+
## Development
|
|
336
|
+
|
|
337
|
+
See the [Development](#development) section below for information on working with this package.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Development
|
|
342
|
+
|
|
343
|
+
### **Before You Begin**
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
# 1. Install pgpm
|
|
347
|
+
npm install -g pgpm
|
|
348
|
+
|
|
349
|
+
# 2. Start Postgres (Docker or local)
|
|
350
|
+
pgpm docker start
|
|
351
|
+
|
|
352
|
+
# 3. Load PG* environment variables (PGHOST, PGUSER, ...)
|
|
353
|
+
eval "$(pgpm env)"
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
### **Starting a New Project**
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# 1. Create a workspace
|
|
362
|
+
pgpm init --workspace
|
|
363
|
+
cd my-app
|
|
364
|
+
|
|
365
|
+
# 2. Create your first module
|
|
366
|
+
pgpm init
|
|
367
|
+
|
|
368
|
+
# 3. Add a migration
|
|
369
|
+
pgpm add some_change
|
|
370
|
+
|
|
371
|
+
# 4. Deploy (auto-creates database)
|
|
372
|
+
pgpm deploy --createdb
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
### **Working With an Existing Project**
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
# 1. Clone and enter the project
|
|
381
|
+
git clone <repo> && cd <project>
|
|
382
|
+
|
|
383
|
+
# 2. Install dependencies
|
|
384
|
+
pnpm install
|
|
385
|
+
|
|
386
|
+
# 3. Deploy locally
|
|
387
|
+
pgpm deploy --createdb
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
### **Testing a Module Inside a Workspace**
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
# 1. Install workspace deps
|
|
396
|
+
pnpm install
|
|
397
|
+
|
|
398
|
+
# 2. Enter the module directory
|
|
399
|
+
cd packages/<some-module>
|
|
400
|
+
|
|
401
|
+
# 3. Run tests in watch mode
|
|
402
|
+
pnpm test:watch
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## Related Tooling
|
|
406
|
+
|
|
407
|
+
* [pgpm](https://github.com/launchql/launchql/tree/main/packages/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
|
|
408
|
+
* [pgsql-test](https://github.com/launchql/launchql/tree/main/packages/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
|
|
409
|
+
* [supabase-test](https://github.com/launchql/launchql/tree/main/packages/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
410
|
+
* [graphile-test](https://github.com/launchql/launchql/tree/main/packages/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
|
|
411
|
+
* [pgsql-parser](https://github.com/launchql/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
|
|
412
|
+
* [libpg-query-node](https://github.com/launchql/libpg-query-node): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
|
|
413
|
+
* [pg-proto-parser](https://github.com/launchql/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
|
|
414
|
+
|
|
415
|
+
## Disclaimer
|
|
416
|
+
|
|
417
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
418
|
+
|
|
419
|
+
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# launchql-database-jobs extension
|
|
2
2
|
comment = 'launchql-database-jobs extension'
|
|
3
|
-
default_version = '0.
|
|
3
|
+
default_version = '0.5.0'
|
|
4
4
|
module_pathname = '$libdir/launchql-database-jobs'
|
|
5
5
|
requires = 'plpgsql,uuid-ossp,pgcrypto,launchql-default-roles,launchql-verify'
|
|
6
6
|
relocatable = false
|
package/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgpm/database-jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Database-specific job handling and queue management",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"bundle": "
|
|
9
|
+
"bundle": "pgpm package",
|
|
10
10
|
"test": "jest",
|
|
11
11
|
"test:watch": "jest --watch"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
|
-
"
|
|
14
|
+
"pgpm": "^0.2.0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pgpm/default-roles": "0.
|
|
18
|
-
"@pgpm/verify": "0.
|
|
17
|
+
"@pgpm/default-roles": "0.6.0",
|
|
18
|
+
"@pgpm/verify": "0.6.0"
|
|
19
19
|
},
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "https://github.com/launchql/
|
|
22
|
+
"url": "https://github.com/launchql/pgpm-modules"
|
|
23
23
|
},
|
|
24
|
-
"homepage": "https://github.com/launchql/
|
|
24
|
+
"homepage": "https://github.com/launchql/pgpm-modules",
|
|
25
25
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/launchql/
|
|
26
|
+
"url": "https://github.com/launchql/pgpm-modules/issues"
|
|
27
27
|
},
|
|
28
|
-
"gitHead": "
|
|
28
|
+
"gitHead": "c7d0eae588d7a764b382a330c8b853b341b13fb2"
|
|
29
29
|
}
|