Skip to content

Commit dc73871

Browse files
committed
fix(requests): serialize request creation per user
The quota check read its counts and then saved with nothing in between to stop a second request from the same user passing the same check, so concurrent requests could all clear a quota that only had room for one. The collection request modal submits its parts in parallel, so this was reachable from the UI. Request creation now runs under a per-user lock, which also closes the same-user duplicate and auto-request races that had the same shape. The lock is taken before any repository call, so a waiter holds no pool connection while blocked.
1 parent 5fd216e commit dc73871

4 files changed

Lines changed: 121 additions & 4 deletions

File tree

server/entity/MediaRequest.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import assert from 'node:assert/strict';
2+
import { beforeEach, describe, it, mock } from 'node:test';
3+
4+
import ExternalAPI from '@server/api/externalapi';
5+
import { MediaType } from '@server/constants/media';
6+
import { getRepository } from '@server/datasource';
7+
import {
8+
DuplicateMediaRequestError,
9+
MediaRequest,
10+
QuotaRestrictedError,
11+
} from '@server/entity/MediaRequest';
12+
import { User } from '@server/entity/User';
13+
import { setupTestDb } from '@server/test/db';
14+
15+
// get is a prototype method unlike getMovie, and replaces the cache lookup too
16+
const externalApiGetMock = mock.method(
17+
ExternalAPI.prototype as unknown as {
18+
get: (endpoint: string) => Promise<unknown>;
19+
},
20+
'get',
21+
async (endpoint: string) => {
22+
const movieId = Number(endpoint.replace('/movie/', ''));
23+
24+
if (!movieId) {
25+
throw new Error(`Unstubbed external endpoint: ${endpoint}`);
26+
}
27+
28+
return {
29+
id: movieId,
30+
external_ids: {},
31+
// Skips getMovie's localized fallback call
32+
videos: { results: [{ type: 'Trailer', key: 'trailer' }] },
33+
};
34+
}
35+
).mock;
36+
37+
mock.method(MediaRequest, 'sendNotification', async () => undefined);
38+
39+
setupTestDb();
40+
41+
beforeEach(() => {
42+
externalApiGetMock.resetCalls();
43+
});
44+
45+
async function seedRequester(movieQuotaLimit: number): Promise<User> {
46+
const userRepository = getRepository(User);
47+
48+
const requester = await userRepository.findOneOrFail({
49+
where: { email: 'friend@seerr.dev' },
50+
});
51+
requester.movieQuotaLimit = movieQuotaLimit;
52+
53+
return userRepository.save(requester);
54+
}
55+
56+
function requestMovies(mediaIds: number[], requester: User) {
57+
return Promise.allSettled(
58+
mediaIds.map((mediaId) =>
59+
MediaRequest.request(
60+
{ mediaId, mediaType: MediaType.MOVIE, is4k: false },
61+
requester
62+
)
63+
)
64+
);
65+
}
66+
67+
function rejections(results: PromiseSettledResult<MediaRequest>[]) {
68+
return results.filter(
69+
(result): result is PromiseRejectedResult => result.status === 'rejected'
70+
);
71+
}
72+
73+
describe('MediaRequest.request', () => {
74+
it('rejects the second of two concurrent requests at the movie quota', async () => {
75+
const requestRepository = getRepository(MediaRequest);
76+
const requester = await seedRequester(1);
77+
78+
const results = await requestMovies([11111, 22222], requester);
79+
const rejected = rejections(results);
80+
81+
assert.strictEqual(rejected.length, 1);
82+
assert.ok(rejected[0].reason instanceof QuotaRestrictedError);
83+
assert.strictEqual(await requestRepository.count(), 1);
84+
assert.strictEqual(externalApiGetMock.callCount(), 1);
85+
});
86+
87+
it('rejects a concurrent duplicate request for the same movie', async () => {
88+
const requestRepository = getRepository(MediaRequest);
89+
const requester = await seedRequester(5);
90+
91+
const results = await requestMovies([33333, 33333], requester);
92+
const rejected = rejections(results);
93+
94+
assert.strictEqual(rejected.length, 1);
95+
assert.ok(rejected[0].reason instanceof DuplicateMediaRequestError);
96+
assert.strictEqual(await requestRepository.count(), 1);
97+
assert.strictEqual(externalApiGetMock.callCount(), 2);
98+
});
99+
});

server/entity/MediaRequest.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Permission } from '@server/lib/permissions';
1414
import { getSettings } from '@server/lib/settings';
1515
import logger from '@server/logger';
1616
import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper';
17+
import requestLock from '@server/utils/requestLock';
1718
import { truncate } from 'lodash';
1819
import {
1920
AfterInsert,
@@ -48,6 +49,16 @@ export class MediaRequest {
4849
requestBody: MediaRequestBody,
4950
user: User,
5051
options: MediaRequestOptions = {}
52+
): Promise<MediaRequest> {
53+
return requestLock.dispatch(requestBody.userId || user.id, () =>
54+
MediaRequest.createRequest(requestBody, user, options)
55+
);
56+
}
57+
58+
private static async createRequest(
59+
requestBody: MediaRequestBody,
60+
user: User,
61+
options: MediaRequestOptions
5162
): Promise<MediaRequest> {
5263
const tmdb = new TheMovieDb();
5364
const mediaRepository = getRepository(Media);

server/utils/asyncLock.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ class AsyncLock {
3737
setImmediate(() => this.ee.emit(key));
3838
};
3939

40-
public dispatch = async (
40+
public dispatch = async <T>(
4141
key: string | number,
42-
callback: () => Promise<void>
43-
) => {
42+
callback: () => Promise<T>
43+
): Promise<T> => {
4444
const skey = String(key);
4545
await this.acquire(skey);
4646
try {
47-
await callback();
47+
return await callback();
4848
} finally {
4949
this.release(skey);
5050
}

server/utils/requestLock.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import AsyncLock from '@server/utils/asyncLock';
2+
3+
// Keyed on user id. Never dispatch from a subscriber or transaction as a waiter
4+
// would block while holding the save's connection.
5+
const requestLock = new AsyncLock();
6+
7+
export default requestLock;

0 commit comments

Comments
 (0)