Skip to content

Commit d07353b

Browse files
committed
feat: 增加后端请求并发配置(前端 >= 2.17.25)
1 parent 30d4f95 commit d07353b

10 files changed

Lines changed: 716 additions & 71 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.23.31",
3+
"version": "2.23.33",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"packageManager": "pnpm@11.0.9",

backend/src/restful/settings.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { InternalServerError } from '@/restful/errors';
44
import $ from '@/core/app';
55
import Gist, { getGithubGistBaseURL } from '@/utils/gist';
66
import { clearLogSettingsCache } from '@/utils/debug-logs';
7+
import {
8+
BACKEND_REQUEST_CONCURRENCY_SETTING,
9+
BACKEND_REQUEST_CONCURRENCY_WAIT_TIME_SETTING,
10+
} from '@/utils/request-concurrency';
711

812
const ARTIFACT_STORE_SETTING_KEYS = [
913
'gistToken',
@@ -92,6 +96,36 @@ async function updateSettings(req, res) {
9296
delete newSettings.logsMaxCount;
9397
}
9498
}
99+
if (BACKEND_REQUEST_CONCURRENCY_SETTING in newSettings) {
100+
const rawConcurrency =
101+
newSettings[BACKEND_REQUEST_CONCURRENCY_SETTING];
102+
const value = Number(rawConcurrency);
103+
if (
104+
rawConcurrency === null ||
105+
rawConcurrency === undefined ||
106+
rawConcurrency === '' ||
107+
!Number.isInteger(value) ||
108+
value < 1
109+
) {
110+
delete newSettings[BACKEND_REQUEST_CONCURRENCY_SETTING];
111+
}
112+
}
113+
if (BACKEND_REQUEST_CONCURRENCY_WAIT_TIME_SETTING in newSettings) {
114+
const rawWaitTime =
115+
newSettings[BACKEND_REQUEST_CONCURRENCY_WAIT_TIME_SETTING];
116+
const value = Number(rawWaitTime);
117+
if (
118+
rawWaitTime === null ||
119+
rawWaitTime === undefined ||
120+
rawWaitTime === '' ||
121+
!Number.isInteger(value) ||
122+
value < 0
123+
) {
124+
delete newSettings[
125+
BACKEND_REQUEST_CONCURRENCY_WAIT_TIME_SETTING
126+
];
127+
}
128+
}
95129
$.write(newSettings, SETTINGS_KEY);
96130
clearLogSettingsCache();
97131
if (shouldRefreshArtifactStoreForSettingsPatch(req.body)) {

backend/src/test/proxy-processors/resolve-domain.spec.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach } from 'mocha';
33

44
import $ from '@/core/app';
55
import PROCESSORS, { ApplyProcessor } from '@/core/proxy-utils/processors';
6+
import { SETTINGS_KEY } from '@/constants';
67
import resourceCache from '@/utils/resource-cache';
78
import { hex_md5 } from '@/vendor/md5';
89

@@ -14,15 +15,18 @@ function sleep(ms) {
1415

1516
describe('Resolve Domain Operator', function () {
1617
let originalGoogleResolver;
18+
let originalRead;
1719
let cacheKeys;
1820

1921
beforeEach(function () {
2022
originalGoogleResolver = ResolveDomainOperator.resolver.Google;
23+
originalRead = $.read.bind($);
2124
cacheKeys = [];
2225
});
2326

2427
afterEach(function () {
2528
ResolveDomainOperator.resolver.Google = originalGoogleResolver;
29+
$.read = originalRead;
2630
cacheKeys.forEach((key) => {
2731
delete resourceCache.resourceCache[key];
2832
});
@@ -146,6 +150,67 @@ describe('Resolve Domain Operator', function () {
146150
expect(maxActiveRequests).to.equal(15);
147151
});
148152

153+
it('does not use backend request concurrency as the default', async function () {
154+
$.read = (key) => {
155+
if (key === SETTINGS_KEY) return { backendRequestConcurrency: 1 };
156+
return originalRead(key);
157+
};
158+
const domains = Array.from({ length: 20 }, (_, index) => ({
159+
name: `Node ${index}`,
160+
server: `backend-setting-${index}.example.com`,
161+
port: 443,
162+
}));
163+
let activeRequests = 0;
164+
let maxActiveRequests = 0;
165+
166+
ResolveDomainOperator.resolver.Google = async (domain) => {
167+
activeRequests += 1;
168+
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
169+
await sleep(5);
170+
activeRequests -= 1;
171+
return `192.0.2.${Number(domain.match(/\d+/)[0]) + 1}`;
172+
};
173+
174+
const processor = ResolveDomainOperator({
175+
provider: 'Google',
176+
type: 'IPv4',
177+
});
178+
await ApplyProcessor(processor, domains);
179+
180+
expect(maxActiveRequests).to.equal(15);
181+
});
182+
183+
it('prefers explicit domain resolver concurrency over backend request concurrency', async function () {
184+
$.read = (key) => {
185+
if (key === SETTINGS_KEY) return { backendRequestConcurrency: 1 };
186+
return originalRead(key);
187+
};
188+
const domains = Array.from({ length: 5 }, (_, index) => ({
189+
name: `Node ${index}`,
190+
server: `explicit-domain-${index}.example.com`,
191+
port: 443,
192+
}));
193+
let activeRequests = 0;
194+
let maxActiveRequests = 0;
195+
196+
ResolveDomainOperator.resolver.Google = async (domain) => {
197+
activeRequests += 1;
198+
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
199+
await sleep(5);
200+
activeRequests -= 1;
201+
return `192.0.2.${Number(domain.match(/\d+/)[0]) + 1}`;
202+
};
203+
204+
const processor = ResolveDomainOperator({
205+
provider: 'Google',
206+
type: 'IPv4',
207+
concurrency: 2,
208+
});
209+
await ApplyProcessor(processor, domains);
210+
211+
expect(maxActiveRequests).to.equal(2);
212+
});
213+
149214
it('rejects invalid concurrency values', function () {
150215
expect(() =>
151216
ResolveDomainOperator({

backend/src/test/restful/settings.spec.js

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,54 @@
11
import { expect } from 'chai';
2-
import { describe, it } from 'mocha';
2+
import { afterEach, describe, it } from 'mocha';
33

4-
import {
4+
import $ from '@/core/app';
5+
import { SETTINGS_KEY } from '@/constants';
6+
import registerSettingsRoutes, {
57
getGithubAvatarApiUrl,
68
shouldRefreshArtifactStoreForSettingsPatch,
79
} from '@/restful/settings';
810

11+
function createRouteApp() {
12+
const handlers = new Map();
13+
const app = {
14+
handlers,
15+
route(pattern) {
16+
const chain = {};
17+
chain.get = (handler) => {
18+
handlers.set(`GET ${pattern}`, handler);
19+
return chain;
20+
};
21+
chain.patch = (handler) => {
22+
handlers.set(`PATCH ${pattern}`, handler);
23+
return chain;
24+
};
25+
return chain;
26+
},
27+
};
28+
29+
return app;
30+
}
31+
32+
function createResponse(routePath) {
33+
return {
34+
body: null,
35+
req: {
36+
route: {
37+
path: routePath,
38+
},
39+
},
40+
statusCode: 200,
41+
status(code) {
42+
this.statusCode = code;
43+
return this;
44+
},
45+
json(payload) {
46+
this.body = payload;
47+
return this;
48+
},
49+
};
50+
}
51+
952
describe('settings routes', function () {
1053
describe('artifact store refresh detection', function () {
1154
it('refreshes when GitHub API URL changes', function () {
@@ -71,4 +114,105 @@ describe('settings routes', function () {
71114
).to.equal('https://litegist.example.com/api/users/xream');
72115
});
73116
});
117+
118+
describe('backend request concurrency settings', function () {
119+
const originalRead = $.read.bind($);
120+
const originalWrite = $.write.bind($);
121+
122+
afterEach(function () {
123+
$.read = originalRead;
124+
$.write = originalWrite;
125+
});
126+
127+
async function patchSettings(initialSettings, body) {
128+
const state = {
129+
[SETTINGS_KEY]: initialSettings,
130+
};
131+
$.read = (key) => state[key];
132+
$.write = (data, key) => {
133+
state[key] = data;
134+
return true;
135+
};
136+
137+
const app = createRouteApp();
138+
registerSettingsRoutes(app);
139+
const patchHandler = app.handlers.get('PATCH /api/settings');
140+
const res = createResponse('/api/settings');
141+
142+
await patchHandler({ body }, res);
143+
144+
expect(res.body.status).to.equal('success');
145+
return state[SETTINGS_KEY];
146+
}
147+
148+
it('persists positive integer backend request concurrency', async function () {
149+
const settings = await patchSettings(
150+
{},
151+
{ backendRequestConcurrency: '15' },
152+
);
153+
154+
expect(settings).to.deep.include({
155+
backendRequestConcurrency: '15',
156+
});
157+
});
158+
159+
it('allows backend request concurrency above proxy app guidance', async function () {
160+
const settings = await patchSettings(
161+
{},
162+
{ backendRequestConcurrency: 21 },
163+
);
164+
165+
expect(settings).to.deep.include({
166+
backendRequestConcurrency: 21,
167+
});
168+
});
169+
170+
it('clears invalid backend request concurrency values', async function () {
171+
const invalidValues = ['', 'abc', '1.5', 0, -1, null];
172+
173+
for (const value of invalidValues) {
174+
const settings = await patchSettings(
175+
{ backendRequestConcurrency: 8 },
176+
{ backendRequestConcurrency: value },
177+
);
178+
179+
expect(settings).to.not.have.property(
180+
'backendRequestConcurrency',
181+
);
182+
}
183+
});
184+
185+
it('persists backend request concurrency wait time values', async function () {
186+
const zeroWaitSettings = await patchSettings(
187+
{},
188+
{ backendRequestConcurrencyWaitTime: 0 },
189+
);
190+
const positiveWaitSettings = await patchSettings(
191+
{},
192+
{ backendRequestConcurrencyWaitTime: '50' },
193+
);
194+
195+
expect(zeroWaitSettings).to.deep.include({
196+
backendRequestConcurrencyWaitTime: 0,
197+
});
198+
expect(positiveWaitSettings).to.deep.include({
199+
backendRequestConcurrencyWaitTime: '50',
200+
});
201+
});
202+
203+
it('clears invalid backend request concurrency wait time values', async function () {
204+
const invalidValues = ['', 'abc', '1.5', -1, null];
205+
206+
for (const value of invalidValues) {
207+
const settings = await patchSettings(
208+
{ backendRequestConcurrencyWaitTime: 8 },
209+
{ backendRequestConcurrencyWaitTime: value },
210+
);
211+
212+
expect(settings).to.not.have.property(
213+
'backendRequestConcurrencyWaitTime',
214+
);
215+
}
216+
});
217+
});
74218
});

backend/src/test/utils/download.spec.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ let tempDir;
2626
let previousDataBasePath;
2727
let capturedUrls;
2828
let errorLogs;
29+
let activeRequests;
30+
let maxActiveRequests;
31+
let requestDelay;
32+
33+
function sleep(ms) {
34+
return new Promise((resolve) => setTimeout(resolve, ms));
35+
}
2936

3037
describe('download github proxy regex', function () {
3138
before(function () {
@@ -76,6 +83,9 @@ describe('download github proxy regex', function () {
7683
beforeEach(function () {
7784
capturedUrls = [];
7885
errorLogs = [];
86+
activeRequests = 0;
87+
maxActiveRequests = 0;
88+
requestDelay = 0;
7989
state = {
8090
[SETTINGS_KEY]: {
8191
githubProxy: 'https://ghproxy.test',
@@ -109,6 +119,10 @@ describe('download github proxy regex', function () {
109119
openApi.HTTP = () => ({
110120
get: async ({ url }) => {
111121
capturedUrls.push(url);
122+
activeRequests += 1;
123+
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
124+
if (requestDelay > 0) await sleep(requestDelay);
125+
activeRequests -= 1;
112126
return {
113127
body: 'test-body',
114128
headers: {},
@@ -164,4 +178,27 @@ describe('download github proxy regex', function () {
164178
expect(errorLogs).to.have.length(1);
165179
expect(errorLogs[0]).to.contain('GitHub 加速代理匹配正则无效');
166180
});
181+
182+
it('limits concurrent outbound download requests by backend setting', async function () {
183+
state[SETTINGS_KEY].backendRequestConcurrency = 2;
184+
requestDelay = 5;
185+
186+
await Promise.all(
187+
Array.from({ length: 5 }, (_, index) =>
188+
download(`https://example.com/archive-${index}.txt`),
189+
),
190+
);
191+
192+
expect(maxActiveRequests).to.equal(2);
193+
});
194+
195+
it('uses cached download content without a new outbound request', async function () {
196+
await download('https://example.com/cached.txt');
197+
await download('https://example.com/cached.txt');
198+
199+
expect(capturedUrls).to.deep.equal([
200+
'https://example.com/cached.txt',
201+
]);
202+
expect(maxActiveRequests).to.equal(1);
203+
});
167204
});

0 commit comments

Comments
 (0)