mongo-locks 2.0.1 → 3.0.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/README.md CHANGED
@@ -1,140 +1,60 @@
1
1
  # mongo-locks
2
+
2
3
  Simple and bountiful locks to avoid doing the same operation multiple times
3
4
 
4
5
  The purpose of this package is simply to avoid doing the same thing twice at the same time
5
6
  and make your system a bit more user-proof.
6
7
 
7
- Usage, if your version of node supports await/async:
8
+ ```ts
9
+ import { LockManager } from "mongo-locks";
10
+ import { MongoClient } from "mongodb";
11
+
12
+ const client = new MongoClient("mongodb://localhost:27017");
13
+ const lockManager = new LockManager(client.db().collection("mongo-locks"));
8
14
 
9
- ```js
10
15
  async function doStuff() {
11
- var freeLock = () => {}; //no-op, you can also use locks.noop
16
+ await using lock = lockManager.lock("unique string key");
12
17
 
13
- //... code
18
+ if (!lock.locked) {
19
+ console.log("Lock already taken");
20
+ return;
21
+ }
14
22
 
15
- try {
16
- //...
17
- freeLock = await locks.lock("unique string key");
23
+ // do stuff
18
24
 
19
- //sensitive code
20
- } catch(err) {
21
- //...
22
- } finally {
23
- freeLock(); /* Gets started on releasing lock, can be awaited */
24
- }
25
+ //lock is automatically freed
25
26
  }
26
27
  ```
27
28
 
28
- Locks are automatically freed within one minute or two, as the purpose of this package is just to protect against
29
- race conditions and doing the same action several times in a short span.
30
-
31
- For a more concrete example:
29
+ If the process crashes while a lock is active, MongoDB will automatically free it after one minute or two.
32
30
 
33
- Imagine you have a "like" feature on your site, where users can like posts. Maybe you would
34
- process likes this way:
31
+ If you don't want to use the `using` syntax, you can do it this way:
35
32
 
36
- ```js
37
- const mongoose = require('mongoose');
33
+ ```ts
34
+ async function doStuff() {
35
+ const lock = await lockManager.lock("unique string key");
38
36
 
39
- router.all("/like", isUserLoggedIn, (req, res, next) => {
40
- /* Silently ignore attempt to like an already liked post */
41
- if (req.user.doesLike(req.post.id)) {
42
- return res.redirect(req.post.getLink());
37
+ if (!lock.locked) {
38
+ console.log("Lock already taken");
39
+ return;
43
40
  }
44
41
 
45
- /* Add post reference to user, and increase likes for post */
46
- Promise.all([
47
- req.user.update({$push: {likedPosts: {ref: req.post.id, title: req.post.title}}}),
48
- req.post.update({$inc: {likes: 1}})
49
- ]).then(
50
- () => res.redirect(req.post.getLink()), //when all done
51
- next //in case of error, next(err) is called
52
- );
53
- });
54
- ```
55
-
56
- The problem with this is that if a user accesses the "like" url twice very fast, for whatever
57
- reason, the server can add the like on one hand while the silent check is already passed successfully
58
- on the other hand, and boom, the user liked the same post twice!
59
-
60
- This package aims to solve that problem. The code would now look like:
61
-
62
- ```js
63
- const mongoose = require('mongoose');
64
- const locks = require('mongo-locks');
65
-
66
- locks.init(mongoose.connection); //do just once across the whole app
67
-
68
- router.all("/like", isUserLoggedIn, (req, res, next) => {
69
- /* Silently ignore attempt to like an already liked post */
70
- if (req.user.doesLike(req.post.id)) {
71
- return res.redirect(req.post.getLink());
42
+ try {
43
+ // do stuff
44
+ } finally {
45
+ await lock.free(); // or await lock[Symbol.asyncDispose]();
72
46
  }
73
-
74
- var freeLock = () => {};
75
- /* Add post reference to user, and increase likes for post */
76
- locks.lock("like", req.user.id, req.post.id).then((free) => {
77
- freeLock = free;
78
- return Promise.all([
79
- req.user.update({$push: {likedPosts: {ref: req.post.id, title: req.post.title}}}),
80
- req.post.update({$inc: {likes: 1}})
81
- ]);
82
- }).then(() => res.redirect(req.post.getLink()), next)
83
- .then(freeLock, freeLock);
84
- });
47
+ }
85
48
  ```
86
49
 
87
- Internally, this package creates a collection and tries to insert locks in
88
- that collection. It relies on MongoDB's unique indexes to guarantee an error
89
- is returned in case the same lock is used twice.
50
+ ## Mongoose
90
51
 
52
+ Mongoose let's you access the underlying collection of a model, so you can use it like this:
91
53
 
92
- ## API
54
+ ```ts
55
+ import { LockManager } from "mongo-locks";
56
+ import mongoose from "mongoose";
93
57
 
94
- All the functions except for getting and setting the parameters return Promises.
95
-
96
- ### .init(connection: mongoose.connection[, options])
97
-
98
- Initialize the connection to the mongo database, needed before any calls to the other functions.
99
-
100
- `options` is made of the following:
101
-
102
- #### collection
103
-
104
- The collection this module will create and use to store locks, by default `locks`
105
-
106
- ### .lock([action1[, action2[, action3[, ...])
107
-
108
- Create a lock for the combination of said actions, return a Promise that is rejected if lock creation failed.
109
-
110
- The lock's id will actually be the various arguments converted to string and joined with `"-"`, so passing objects
111
- to this function is ill-advised.
112
-
113
- If lock creation succeeds, the Promise resolves to a function to free the lock.
114
-
115
- If lock creation fails, an exception is thrown.
116
-
117
- ### .free([action1[, action2[, action3[, ...])
118
-
119
- Frees the lock. If you want to wait until the lock is freed, you can chain it with `.then`.
120
-
121
- ### .resfresh([action1[, action2[, action3[, ...])
122
-
123
- Refreshes the lock, giving it one minute more. Chainable.
124
-
125
- ## MongoDb model
126
-
127
- Model created:
128
-
129
- ``` js
130
- // This module will create a Mongoose model
131
- // collection with schema:
132
- Locks = new mongoose.Schema({
133
- createdAt: Date,
134
- refreshedAt: Date, //indexed, expires after 60 seconds
135
- action: String //uniquely indexed
136
- });
58
+ const lockManager = new LockManager(mongoose.model("Lock").collection);
59
+ // ...
137
60
  ```
138
-
139
- The collection is called "locks" by default, but you can set the name you want in the `options`
140
- parameter of the `init()` function.
package/dist/index.js CHANGED
@@ -10,47 +10,108 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.MongoLocksError = void 0;
13
- const schema_1 = require("./src/schema");
13
+ const mongodb_1 = require("mongodb");
14
14
  class MongoLocksError extends Error {
15
15
  constructor(message) {
16
16
  super(message);
17
17
  }
18
18
  }
19
19
  exports.MongoLocksError = MongoLocksError;
20
- const makeLockId = (array) => Array.prototype.slice.apply(array).join(":");
21
- class LockManager {
22
- constructor() {
23
- this.Locks = null;
24
- this.MongoLocksError = MongoLocksError;
25
- this.noop = () => __awaiter(this, void 0, void 0, function* () { });
20
+ const makeKey = (key) => {
21
+ // Arrays mess up the unique MongoDB indexes
22
+ return Array.isArray(key) ? { _key_array: key } : key;
23
+ };
24
+ class Lock {
25
+ constructor(_id, action, collection, locked) {
26
+ this._id = _id;
27
+ this.action = action;
28
+ this.collection = collection;
29
+ this.locked = locked;
30
+ if (this.locked) {
31
+ (() => __awaiter(this, void 0, void 0, function* () {
32
+ while (this.locked) {
33
+ yield new Promise((resolve) => setTimeout(resolve, 10000));
34
+ yield this.refresh().catch(() => {
35
+ console.error("Failed to refresh lock", action);
36
+ });
37
+ }
38
+ }))();
39
+ }
26
40
  }
27
- init(connection, options) {
28
- options = options || {};
29
- this.Locks = connection.model(options.collection || "Locks", schema_1.default);
41
+ /**
42
+ * Instead of calling `this.free` directly you can use the `using` syntax
43
+ *
44
+ * @returns true if the lock was successfully released. False if the lock was not found
45
+ */
46
+ free() {
47
+ return __awaiter(this, void 0, void 0, function* () {
48
+ if (!this.locked) {
49
+ return false;
50
+ }
51
+ const result = yield this.collection.deleteOne({ _id: this._id });
52
+ return result.deletedCount === 1;
53
+ });
30
54
  }
31
- lock(...actions) {
55
+ /**
56
+ * Refreshes the lock's TTL
57
+ *
58
+ * Automatically called every 10 seconds if the lock is still active, no
59
+ * need to call this manually
60
+ *
61
+ * @returns true if the lock was successfully refreshed. False if the lock was not found
62
+ */
63
+ refresh() {
32
64
  return __awaiter(this, void 0, void 0, function* () {
33
- if (!this.Locks) {
34
- throw new MongoLocksError("You must initialize mongo-locks with a mongoose connection");
65
+ if (!this.locked) {
66
+ return false;
35
67
  }
36
- const lockId = makeLockId(actions);
37
- const l = new this.Locks();
38
- l.action = lockId;
39
- yield l.save();
40
- return () => this.Locks.deleteOne({ action: lockId }).exec();
68
+ const res = yield this.collection.updateOne({ _id: this._id }, { $set: { refreshedAt: new Date(), expiresAt: new Date(Date.now() + 60000) } });
69
+ this.locked = res.matchedCount === 1;
70
+ return this.locked;
41
71
  });
42
72
  }
43
- refresh(...actions) {
44
- if (!this.Locks) {
45
- throw new MongoLocksError("You must initialize mongo-locks with a mongoose connection");
46
- }
47
- return this.Locks.updateOne({ action: makeLockId(actions) }, { $set: { refreshedAt: Date.now() } }, { upsert: true }).exec();
73
+ [Symbol.dispose]() {
74
+ this.free();
48
75
  }
49
- free(...actions) {
50
- if (!this.Locks) {
51
- throw new MongoLocksError("You must initialize mongo-locks with a mongoose connection");
52
- }
53
- return this.Locks.deleteOne({ action: makeLockId(actions) }).exec();
76
+ [Symbol.asyncDispose]() {
77
+ return __awaiter(this, void 0, void 0, function* () {
78
+ return this.free();
79
+ });
80
+ }
81
+ }
82
+ class LockManager {
83
+ constructor(collection) {
84
+ this.MongoLocksError = MongoLocksError;
85
+ this.collection = collection;
86
+ collection.createIndex({ action: 1 }, { unique: true });
87
+ collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
88
+ }
89
+ /**
90
+ * Creates a DB lock based on the key provided
91
+ *
92
+ * @returns a function that when called will release the lock
93
+ */
94
+ lock(key) {
95
+ return __awaiter(this, void 0, void 0, function* () {
96
+ const lockId = makeKey(key);
97
+ const id = new mongodb_1.ObjectId();
98
+ try {
99
+ yield this.collection.insertOne({
100
+ _id: id,
101
+ action: lockId,
102
+ createdAt: new Date(),
103
+ refreshedAt: new Date(),
104
+ expiresAt: new Date(Date.now() + 60000),
105
+ });
106
+ return new Lock(id, lockId, this.collection, true);
107
+ }
108
+ catch (err) {
109
+ if (err instanceof Error && "code" in err && err.code === 11000) {
110
+ return new Lock(id, lockId, this.collection, false);
111
+ }
112
+ throw err;
113
+ }
114
+ });
54
115
  }
55
116
  }
56
- exports.default = new LockManager();
117
+ exports.default = LockManager;
package/index.ts CHANGED
@@ -1,5 +1,4 @@
1
- import * as mongoose from "mongoose";
2
- import lockSchema from "./src/schema";
1
+ import { Collection, ObjectId } from "mongodb";
3
2
 
4
3
  export class MongoLocksError extends Error {
5
4
  constructor(message?: string) {
@@ -7,50 +6,123 @@ export class MongoLocksError extends Error {
7
6
  }
8
7
  }
9
8
 
10
- const makeLockId = (array: any[]) => Array.prototype.slice.apply(array).join(":");
9
+ const makeKey = (key: unknown) => {
10
+ // Arrays mess up the unique MongoDB indexes
11
+ return Array.isArray(key) ? { _key_array: key } : key;
12
+ };
11
13
 
12
- class LockManager {
13
- Locks: mongoose.Model<any> | null = null;
14
- MongoLocksError = MongoLocksError;
15
- noop: () => Promise<any> = async () => {};
14
+ interface LockModel {
15
+ _id: ObjectId;
16
+ action: unknown;
17
+ createdAt: Date;
18
+ refreshedAt: Date;
19
+ expiresAt: Date;
20
+ }
16
21
 
17
- init(connection: mongoose.Connection, options?: { collection?: string }) {
18
- options = options || {};
19
- this.Locks = connection.model(options.collection || "Locks", lockSchema);
22
+ class Lock {
23
+ constructor(
24
+ public _id: ObjectId,
25
+ public action: unknown,
26
+ public collection: Collection<LockModel>,
27
+ public locked: boolean
28
+ ) {
29
+ if (this.locked) {
30
+ (async () => {
31
+ while (this.locked) {
32
+ await new Promise((resolve) => setTimeout(resolve, 10_000));
33
+ await this.refresh().catch(() => {
34
+ console.error("Failed to refresh lock", action);
35
+ });
36
+ }
37
+ })();
38
+ }
20
39
  }
21
40
 
22
- async lock(...actions: any[]): Promise<() => Promise<any>> {
23
- if (!this.Locks) {
24
- throw new MongoLocksError("You must initialize mongo-locks with a mongoose connection");
41
+ /**
42
+ * Instead of calling `this.free` directly you can use the `using` syntax
43
+ *
44
+ * @returns true if the lock was successfully released. False if the lock was not found
45
+ */
46
+ async free(): Promise<boolean> {
47
+ if (!this.locked) {
48
+ return false;
25
49
  }
26
50
 
27
- const lockId = makeLockId(actions);
28
-
29
- const l = new this.Locks();
30
- l.action = lockId;
31
- await l.save();
32
- return () => this.Locks!.deleteOne({ action: lockId }).exec()
51
+ const result = await this.collection.deleteOne({ _id: this._id });
52
+ return result.deletedCount === 1;
33
53
  }
34
54
 
35
- refresh(...actions: any[]) {
36
- if (!this.Locks) {
37
- throw new MongoLocksError("You must initialize mongo-locks with a mongoose connection");
55
+ /**
56
+ * Refreshes the lock's TTL
57
+ *
58
+ * Automatically called every 10 seconds if the lock is still active, no
59
+ * need to call this manually
60
+ *
61
+ * @returns true if the lock was successfully refreshed. False if the lock was not found
62
+ */
63
+ async refresh(): Promise<boolean> {
64
+ if (!this.locked) {
65
+ return false;
38
66
  }
39
67
 
40
- return this.Locks.updateOne(
41
- { action: makeLockId(actions) },
42
- { $set: { refreshedAt: Date.now() } },
43
- { upsert: true }
44
- ).exec();
68
+ const res = await this.collection.updateOne(
69
+ { _id: this._id },
70
+ { $set: { refreshedAt: new Date(), expiresAt: new Date(Date.now() + 60_000) } }
71
+ );
72
+
73
+ this.locked = res.matchedCount === 1;
74
+
75
+ return this.locked;
45
76
  }
46
77
 
47
- free(...actions: any[]) {
48
- if (!this.Locks) {
49
- throw new MongoLocksError("You must initialize mongo-locks with a mongoose connection");
50
- }
78
+ [Symbol.dispose]() {
79
+ this.free();
80
+ }
81
+
82
+ async [Symbol.asyncDispose]() {
83
+ return this.free();
84
+ }
85
+ }
86
+
87
+ class LockManager {
88
+ collection: Collection<LockModel>;
89
+ MongoLocksError = MongoLocksError;
90
+
91
+ constructor(collection: Collection) {
92
+ this.collection = collection as unknown as Collection<LockModel>;
51
93
 
52
- return this.Locks.deleteOne({ action: makeLockId(actions) }).exec();
94
+ collection.createIndex({ action: 1 }, { unique: true });
95
+ collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
96
+ }
97
+
98
+ /**
99
+ * Creates a DB lock based on the key provided
100
+ *
101
+ * @returns a function that when called will release the lock
102
+ */
103
+ async lock(key: unknown): Promise<Lock> {
104
+ const lockId = makeKey(key);
105
+ const id = new ObjectId();
106
+
107
+ try {
108
+ await this.collection.insertOne({
109
+ _id: id,
110
+ action: lockId,
111
+ createdAt: new Date(),
112
+ refreshedAt: new Date(),
113
+ expiresAt: new Date(Date.now() + 60_000),
114
+ });
115
+
116
+ return new Lock(id, lockId, this.collection, true);
117
+ } catch (err) {
118
+ if (err instanceof Error && "code" in err && err.code === 11000) {
119
+ return new Lock(id, lockId, this.collection, false);
120
+ }
121
+
122
+ throw err;
123
+ }
53
124
  }
54
125
  }
55
126
 
56
- export default new LockManager();
127
+ export default LockManager;
128
+ export type { LockModel, Lock };
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "mongo-locks",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "description": "Simple and bountiful locks to avoid doing the same operation multiple times",
5
5
  "main": "dist/index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "prepublishOnly": "npm run clean && tsc",
9
+ "clean": "rm -Rf dist"
10
+ },
6
11
  "repository": {
7
12
  "type": "git",
8
13
  "url": "git+https://github.com/coyotte508/mongo-locks.git"
9
14
  },
10
15
  "keywords": [
11
16
  "mongo",
12
- "mongoose",
17
+ "mongodb",
13
18
  "lock",
14
19
  "mutex",
15
20
  "multiple"
@@ -21,21 +26,17 @@
21
26
  },
22
27
  "homepage": "https://github.com/coyotte508/mongo-locks#readme",
23
28
  "peerDependencies": {
24
- "mongoose": ">=4.7.6"
29
+ "mongodb": ">=4"
25
30
  },
26
31
  "devDependencies": {
27
- "mongoose": "^6.0.2",
28
- "typescript": "^4.0.1"
32
+ "@types/node": "^20.12.7",
33
+ "mongodb": "^6.5.0",
34
+ "typescript": "^5.4.5"
29
35
  },
30
36
  "files": [
31
- "src",
32
37
  "dist",
33
38
  "index.d.ts",
34
39
  "index.ts",
35
40
  "tsconfig.json"
36
- ],
37
- "scripts": {
38
- "test": "echo \"Error: no test specified\" && exit 1",
39
- "clean": "rm -Rf dist"
40
- }
41
- }
41
+ ]
42
+ }
@@ -1,23 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const mongoose = require("mongoose");
4
- const Schema = mongoose.Schema;
5
- // define the schema for our user model
6
- const lockSchema = new Schema({
7
- createdAt: {
8
- type: Date,
9
- default: Date.now
10
- },
11
- refreshedAt: {
12
- type: Date,
13
- default: Date.now,
14
- // Locks are not supposed to last longer than a minute
15
- expires: 60
16
- },
17
- action: {
18
- type: String,
19
- unique: true,
20
- required: true
21
- }
22
- });
23
- exports.default = lockSchema;
package/src/schema.ts DELETED
@@ -1,23 +0,0 @@
1
- import * as mongoose from "mongoose";
2
- const Schema = mongoose.Schema;
3
-
4
- // define the schema for our user model
5
- const lockSchema = new Schema({
6
- createdAt: {
7
- type: Date,
8
- default: Date.now
9
- },
10
- refreshedAt: {
11
- type: Date,
12
- default: Date.now,
13
- // Locks are not supposed to last longer than a minute
14
- expires: 60
15
- },
16
- action: {
17
- type: String,
18
- unique: true,
19
- required: true
20
- }
21
- });
22
-
23
- export default lockSchema;