Skip to content

Commit e106f6e

Browse files
authored
Merge branch 'alpha' into postgresURI
2 parents 6f349f2 + 410a1c7 commit e106f6e

10 files changed

+148
-28
lines changed

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,9 +525,26 @@ let api = new ParseServer({
525525
| `idempotencyOptions.paths` | yes | `Array<String>` | `[]` | `.*` (all paths, includes the examples below), <br>`functions/.*` (all functions), <br>`jobs/.*` (all jobs), <br>`classes/.*` (all classes), <br>`functions/.*` (all functions), <br>`users` (user creation / update), <br>`installations` (installation creation / update) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS | An array of path patterns that have to match the request path for request deduplication to be enabled. The mount path must not be included, for example to match the request path `/parse/functions/myFunction` specify the path pattern `functions/myFunction`. A trailing slash of the request path is ignored, for example the path pattern `functions/myFunction` matches both `/parse/functions/myFunction` and `/parse/functions/myFunction/`. |
526526
| `idempotencyOptions.ttl` | yes | `Integer` | `300` | `60` (60 seconds) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL | The duration in seconds after which a request record is discarded from the database. Duplicate requests due to network issues can be expected to arrive within milliseconds up to several seconds. This value must be greater than `0`. |
527527
528-
### Notes <!-- omit in toc -->
528+
### Postgres <!-- omit in toc -->
529+
530+
To use this feature in Postgres, you will need to create a cron job using [pgAdmin](https://www.pgadmin.org/docs/pgadmin4/development/pgagent_jobs.html) or similar to call the Postgres function `idempotency_delete_expired_records()` that deletes expired idempotency records. You can find an example script below. Make sure the script has the same privileges to log into Postgres as Parse Server.
531+
532+
```bash
533+
#!/bin/bash
534+
535+
set -e
536+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
537+
SELECT idempotency_delete_expired_records();
538+
EOSQL
529539
530-
- This feature is currently only available for MongoDB and not for Postgres.
540+
exec "$@"
541+
```
542+
543+
Assuming the script above is named, `parse_idempotency_delete_expired_records.sh`, a cron job that runs the script every 2 minutes may look like:
544+
545+
```bash
546+
2 * * * * /root/parse_idempotency_delete_expired_records.sh >/dev/null 2>&1
547+
```
531548
532549
## Localization
533550

changelogs/CHANGELOG_alpha.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# [5.0.0-alpha.16](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.15...5.0.0-alpha.16) (2022-01-02)
2+
3+
4+
### Features
5+
6+
* add Idempotency to Postgres ([#7750](https://github.com/parse-community/parse-server/issues/7750)) ([0c3feaa](https://github.com/parse-community/parse-server/commit/0c3feaaa1751964c0db89f25674935c3354b1538))
7+
18
# [5.0.0-alpha.15](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.14...5.0.0-alpha.15) (2022-01-02)
29

310

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "5.0.0-alpha.15",
3+
"version": "5.0.0-alpha.16",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {

spec/Idempotency.spec.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ const rest = require('../lib/rest');
66
const auth = require('../lib/Auth');
77
const uuid = require('uuid');
88

9-
describe_only_db('mongo')('Idempotency', () => {
9+
describe('Idempotency', () => {
1010
// Parameters
1111
/** Enable TTL expiration simulated by removing entry instead of waiting for MongoDB TTL monitor which
1212
runs only every 60s, so it can take up to 119s until entry removal - ain't nobody got time for that */
1313
const SIMULATE_TTL = true;
14+
const ttl = 2;
15+
const maxTimeOut = 4000;
16+
1417
// Helpers
1518
async function deleteRequestEntry(reqId) {
1619
const config = Config.get(Parse.applicationId);
@@ -38,9 +41,10 @@ describe_only_db('mongo')('Idempotency', () => {
3841
}
3942
await setup({
4043
paths: ['functions/.*', 'jobs/.*', 'classes/.*', 'users', 'installations'],
41-
ttl: 30,
44+
ttl: ttl,
4245
});
4346
});
47+
4448
// Tests
4549
it('should enforce idempotency for cloud code function', async () => {
4650
let counter = 0;
@@ -56,7 +60,7 @@ describe_only_db('mongo')('Idempotency', () => {
5660
'X-Parse-Request-Id': 'abc-123',
5761
},
5862
};
59-
expect(Config.get(Parse.applicationId).idempotencyOptions.ttl).toBe(30);
63+
expect(Config.get(Parse.applicationId).idempotencyOptions.ttl).toBe(ttl);
6064
await request(params);
6165
await request(params).then(fail, e => {
6266
expect(e.status).toEqual(400);
@@ -83,12 +87,35 @@ describe_only_db('mongo')('Idempotency', () => {
8387
if (SIMULATE_TTL) {
8488
await deleteRequestEntry('abc-123');
8589
} else {
86-
await new Promise(resolve => setTimeout(resolve, 130000));
90+
await new Promise(resolve => setTimeout(resolve, maxTimeOut));
8791
}
8892
await expectAsync(request(params)).toBeResolved();
8993
expect(counter).toBe(2);
9094
});
9195

96+
it_only_db('postgres')('should delete request entry when postgress ttl function is called', async () => {
97+
const client = Config.get(Parse.applicationId).database.adapter._client;
98+
let counter = 0;
99+
Parse.Cloud.define('myFunction', () => {
100+
counter++;
101+
});
102+
const params = {
103+
method: 'POST',
104+
url: 'http://localhost:8378/1/functions/myFunction',
105+
headers: {
106+
'X-Parse-Application-Id': Parse.applicationId,
107+
'X-Parse-Master-Key': Parse.masterKey,
108+
'X-Parse-Request-Id': 'abc-123',
109+
},
110+
};
111+
await expectAsync(request(params)).toBeResolved();
112+
await expectAsync(request(params)).toBeRejected();
113+
await new Promise(resolve => setTimeout(resolve, maxTimeOut));
114+
await client.one('SELECT idempotency_delete_expired_records()');
115+
await expectAsync(request(params)).toBeResolved();
116+
expect(counter).toBe(2);
117+
});
118+
92119
it('should enforce idempotency for cloud code jobs', async () => {
93120
let counter = 0;
94121
Parse.Cloud.job('myJob', () => {

spec/PostgresStorageAdapter.spec.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,17 @@ describe_only_db('postgres')('PostgresStorageAdapter', () => {
558558
await new Promise(resolve => setTimeout(resolve, 2000));
559559
expect(adapter._onchange).toHaveBeenCalled();
560560
});
561+
562+
it('Idempotency class should have function', async () => {
563+
await reconfigureServer();
564+
const adapter = Config.get('test').database.adapter;
565+
const client = adapter._client;
566+
const qs = "SELECT format('%I.%I(%s)', ns.nspname, p.proname, oidvectortypes(p.proargtypes)) FROM pg_proc p INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid) WHERE p.proname = 'idempotency_delete_expired_records'";
567+
const foundFunction = await client.one(qs);
568+
expect(foundFunction.format).toBe("public.idempotency_delete_expired_records()");
569+
await adapter.deleteIdempotencyFunction();
570+
await client.none(qs);
571+
});
561572
});
562573

563574
describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => {

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2440,9 +2440,55 @@ export class PostgresStorageAdapter implements StorageAdapter {
24402440
? fieldNames.map((fieldName, index) => `lower($${index + 3}:name) varchar_pattern_ops`)
24412441
: fieldNames.map((fieldName, index) => `$${index + 3}:name`);
24422442
const qs = `CREATE INDEX IF NOT EXISTS $1:name ON $2:name (${constraintPatterns.join()})`;
2443-
await conn.none(qs, [indexNameOptions.name, className, ...fieldNames]).catch(error => {
2444-
throw error;
2445-
});
2443+
const setIdempotencyFunction = options.setIdempotencyFunction !== undefined ? options.setIdempotencyFunction : false;
2444+
if (setIdempotencyFunction) {
2445+
await this.ensureIdempotencyFunctionExists(options);
2446+
}
2447+
await conn.none(qs, [indexNameOptions.name, className, ...fieldNames])
2448+
.catch(error => {
2449+
if (
2450+
error.code === PostgresDuplicateRelationError &&
2451+
error.message.includes(indexNameOptions.name)
2452+
) {
2453+
// Index already exists. Ignore error.
2454+
} else if (
2455+
error.code === PostgresUniqueIndexViolationError &&
2456+
error.message.includes(indexNameOptions.name)
2457+
) {
2458+
// Cast the error into the proper parse error
2459+
throw new Parse.Error(
2460+
Parse.Error.DUPLICATE_VALUE,
2461+
'A duplicate value for a field with unique values was provided'
2462+
);
2463+
} else {
2464+
throw error;
2465+
}
2466+
});
2467+
}
2468+
2469+
async deleteIdempotencyFunction(
2470+
options?: Object = {}
2471+
): Promise<any> {
2472+
const conn = options.conn !== undefined ? options.conn : this._client;
2473+
const qs = 'DROP FUNCTION IF EXISTS idempotency_delete_expired_records()';
2474+
return conn
2475+
.none(qs)
2476+
.catch(error => {
2477+
throw error;
2478+
});
2479+
}
2480+
2481+
async ensureIdempotencyFunctionExists(
2482+
options?: Object = {}
2483+
): Promise<any> {
2484+
const conn = options.conn !== undefined ? options.conn : this._client;
2485+
const ttlOptions = options.ttl !== undefined ? `${options.ttl} seconds` : '60 seconds';
2486+
const qs = 'CREATE OR REPLACE FUNCTION idempotency_delete_expired_records() RETURNS void LANGUAGE plpgsql AS $$ BEGIN DELETE FROM "_Idempotency" WHERE expire < NOW() - INTERVAL $1; END; $$;';
2487+
return conn
2488+
.none(qs, [ttlOptions])
2489+
.catch(error => {
2490+
throw error;
2491+
});
24462492
}
24472493
}
24482494

src/Controllers/DatabaseController.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import logger from '../logger';
1414
import * as SchemaController from './SchemaController';
1515
import { StorageAdapter } from '../Adapters/Storage/StorageAdapter';
1616
import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter';
17+
import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter';
1718
import SchemaCache from '../Adapters/Cache/SchemaCache';
1819
import type { LoadSchemaOptions } from './types';
1920
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
@@ -394,12 +395,14 @@ const relationSchema = {
394395

395396
class DatabaseController {
396397
adapter: StorageAdapter;
398+
idempotencyOptions: any;
397399
schemaCache: any;
398400
schemaPromise: ?Promise<SchemaController.SchemaController>;
399401
_transactionalSession: ?any;
400402

401-
constructor(adapter: StorageAdapter) {
403+
constructor(adapter: StorageAdapter, idempotencyOptions?: Object = {}) {
402404
this.adapter = adapter;
405+
this.idempotencyOptions = idempotencyOptions;
403406
// We don't want a mutable this.schema, because then you could have
404407
// one request that uses different schemas for different parts of
405408
// it. Instead, use loadSchema to get a schema.
@@ -1713,9 +1716,7 @@ class DatabaseController {
17131716
};
17141717
await this.loadSchema().then(schema => schema.enforceClassExists('_User'));
17151718
await this.loadSchema().then(schema => schema.enforceClassExists('_Role'));
1716-
if (this.adapter instanceof MongoStorageAdapter) {
1717-
await this.loadSchema().then(schema => schema.enforceClassExists('_Idempotency'));
1718-
}
1719+
await this.loadSchema().then(schema => schema.enforceClassExists('_Idempotency'));
17191720

17201721
await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => {
17211722
logger.warn('Unable to ensure uniqueness for usernames: ', error);
@@ -1751,18 +1752,28 @@ class DatabaseController {
17511752
logger.warn('Unable to ensure uniqueness for role name: ', error);
17521753
throw error;
17531754
});
1754-
if (this.adapter instanceof MongoStorageAdapter) {
1755-
await this.adapter
1756-
.ensureUniqueness('_Idempotency', requiredIdempotencyFields, ['reqId'])
1757-
.catch(error => {
1758-
logger.warn('Unable to ensure uniqueness for idempotency request ID: ', error);
1759-
throw error;
1760-
});
17611755

1762-
await this.adapter
1763-
.ensureIndex('_Idempotency', requiredIdempotencyFields, ['expire'], 'ttl', false, {
1756+
await this.adapter
1757+
.ensureUniqueness('_Idempotency', requiredIdempotencyFields, ['reqId'])
1758+
.catch(error => {
1759+
logger.warn('Unable to ensure uniqueness for idempotency request ID: ', error);
1760+
throw error;
1761+
});
1762+
1763+
const isMongoAdapter = this.adapter instanceof MongoStorageAdapter;
1764+
const isPostgresAdapter = this.adapter instanceof PostgresStorageAdapter;
1765+
if (isMongoAdapter || isPostgresAdapter) {
1766+
let options = {};
1767+
if (isMongoAdapter) {
1768+
options = {
17641769
ttl: 0,
1765-
})
1770+
};
1771+
} else if (isPostgresAdapter) {
1772+
options = this.idempotencyOptions;
1773+
options.setIdempotencyFunction = true;
1774+
}
1775+
await this.adapter
1776+
.ensureIndex('_Idempotency', requiredIdempotencyFields, ['expire'], 'ttl', false, options)
17661777
.catch(error => {
17671778
logger.warn('Unable to create TTL index for idempotency expire date: ', error);
17681779
throw error;

src/Controllers/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function getLiveQueryController(options: ParseServerOptions): LiveQueryCo
142142
}
143143

144144
export function getDatabaseController(options: ParseServerOptions): DatabaseController {
145-
const { databaseURI, collectionPrefix, databaseOptions } = options;
145+
const { databaseURI, collectionPrefix, databaseOptions, idempotencyOptions } = options;
146146
let { databaseAdapter } = options;
147147
if (
148148
(databaseOptions ||
@@ -156,7 +156,7 @@ export function getDatabaseController(options: ParseServerOptions): DatabaseCont
156156
} else {
157157
databaseAdapter = loadAdapter(databaseAdapter);
158158
}
159-
return new DatabaseController(databaseAdapter);
159+
return new DatabaseController(databaseAdapter, idempotencyOptions);
160160
}
161161

162162
export function getHooksController(

src/middlewares.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ClientSDK from './ClientSDK';
66
import defaultLogger from './logger';
77
import rest from './rest';
88
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';
9+
import PostgresStorageAdapter from './Adapters/Storage/Postgres/PostgresStorageAdapter';
910

1011
export const DEFAULT_ALLOWED_HEADERS =
1112
'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control';
@@ -431,7 +432,7 @@ export function promiseEnforceMasterKeyAccess(request) {
431432
*/
432433
export function promiseEnsureIdempotency(req) {
433434
// Enable feature only for MongoDB
434-
if (!(req.config.database.adapter instanceof MongoStorageAdapter)) {
435+
if (!((req.config.database.adapter instanceof MongoStorageAdapter) || (req.config.database.adapter instanceof PostgresStorageAdapter))) {
435436
return Promise.resolve();
436437
}
437438
// Get parameters

0 commit comments

Comments
 (0)