Skip to content

Commit 85befec

Browse files
authored
Merge pull request #766 from forcedotcom/sm/cache-limits
@W-18349042 fix: keep caches from growing unbounded in multitenant environments
2 parents 68c0ea4 + a6d9ae9 commit 85befec

4 files changed

Lines changed: 314 additions & 238 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@salesforce/source-tracking",
33
"description": "API for tracking local and remote Salesforce metadata changes",
4-
"version": "7.3.22",
4+
"version": "7.3.23-qa.1",
55
"author": "Salesforce",
66
"license": "Apache-2.0",
77
"main": "lib/index.js",
@@ -51,9 +51,9 @@
5151
},
5252
"dependencies": {
5353
"@oclif/core": "^4.2.10",
54-
"@salesforce/core": "^8.8.7",
54+
"@salesforce/core": "^8.9.1",
5555
"@salesforce/kit": "^3.2.3",
56-
"@salesforce/source-deploy-retrieve": "^12.19.0",
56+
"@salesforce/source-deploy-retrieve": "^12.19.3",
5757
"@salesforce/ts-types": "^2.0.12",
5858
"fast-xml-parser": "^4.5.3",
5959
"graceful-fs": "^4.2.11",

src/shared/remote/remoteSourceTrackingService.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const POLLING_DELAY_MS = 1000;
3737
const CONSECUTIVE_EMPTY_POLLING_RESULT_LIMIT =
3838
(env.getNumber('SF_SOURCE_MEMBER_POLLING_TIMEOUT') ?? 120) / Duration.milliseconds(POLLING_DELAY_MS).seconds;
3939

40+
/** if a cached instance is older than this, it will be purged */
41+
const MAX_INSTANCE_CACHE_TTL = 1000 * 60 * 60 * 1; // 1 hour
42+
4043
/** Options for RemoteSourceTrackingService.getInstance */
4144
export type RemoteSourceTrackingServiceOptions = {
4245
org: Org;
@@ -73,15 +76,20 @@ export type RemoteSourceTrackingServiceOptions = {
7376
* In this example, `ApexClass###MyClass` has been changed in the org because the `serverRevisionCounter` is different
7477
* from the `lastRetrievedFromServer`. When a pull is performed, all of the pulled members will have their counters set
7578
* to the corresponding `RevisionCounter` from the `SourceMember` of the org.
76-
*
77-
* Tracking files are written to the older format described in `MemberRevisionLegacy`
78-
* if the environment variable CURRENT_FILE_VERSION_ENV is not set to 1
79-
*
79+
*
80+
* Tracking files are written to the older format described in `MemberRevisionLegacy`
81+
* if the environment variable CURRENT_FILE_VERSION_ENV is not set to 1
82+
*
8083
* The "in memorgy" storage is in MemberRevision format.
8184
*/
85+
type CachedInstance = {
86+
service: RemoteSourceTrackingService;
87+
lastUsed: number;
88+
};
89+
8290
export class RemoteSourceTrackingService {
8391
/** map of constructed, init'ed instances; key is orgId. It's like a singleton at the org level */
84-
private static instanceMap = new Map<string, RemoteSourceTrackingService>();
92+
private static instanceMap = new Map<string, CachedInstance>();
8593
public readonly filePath: string;
8694

8795
private logger!: PinoLogger;
@@ -112,13 +120,14 @@ export class RemoteSourceTrackingService {
112120
*/
113121
public static async getInstance(options: RemoteSourceTrackingServiceOptions): Promise<RemoteSourceTrackingService> {
114122
const orgId = options.org.getOrgId();
115-
let service = this.instanceMap.get(orgId);
116-
if (!service) {
117-
service = await new RemoteSourceTrackingService(options).init();
118-
this.instanceMap.set(orgId, service);
119-
}
123+
const service = this.instanceMap.get(orgId)?.service ?? (await new RemoteSourceTrackingService(options).init());
124+
this.instanceMap.set(orgId, { service, lastUsed: Date.now() });
125+
// when we get an instance, we make sure old ones are not accumulating. Important in multitenant environments
126+
purgeOldInstances(this.instanceMap);
127+
120128
// even if there was already an instance around, its queries might no longer be accurate (ex: missing new changes but queryFrom would return stale results)
121129
service.queryCache.clear();
130+
service.userQueryCache.clear();
122131
service.org = options.org;
123132
return service;
124133
}
@@ -537,3 +546,12 @@ const formatSourceMemberWarnings = (outstandingSourceMembers: Map<string, Remote
537546

538547
const doesNotMatchServer = (member: MemberRevision): boolean =>
539548
member.RevisionCounter !== member.lastRetrievedFromServer;
549+
550+
const purgeOldInstances = (instances: Map<string, CachedInstance>): void => {
551+
const now = Date.now();
552+
Array.from(instances.entries())
553+
.filter(([, { lastUsed }]) => now - lastUsed > MAX_INSTANCE_CACHE_TTL)
554+
.map(([orgId]) => {
555+
instances.delete(orgId);
556+
});
557+
};

test/unit/remote/remoteSourceTracking.test.ts

Lines changed: 96 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
/* eslint-disable camelcase */
99

1010
import { writeFile, mkdir, readFile } from 'node:fs/promises';
11-
import { existsSync } from 'node:fs';
12-
import { sep, dirname } from 'node:path';
11+
import { existsSync, rmSync } from 'node:fs';
12+
import { sep, dirname, resolve } from 'node:path';
1313
import { MockTestOrgData, instantiateContext, stubContext, restoreContext } from '@salesforce/core/testSetup';
1414
import { EnvVars, envVars, Messages, Org } from '@salesforce/core';
1515
import { expect, config } from 'chai';
@@ -74,7 +74,6 @@ type SetContentsInput = {
7474
};
7575
describe('remoteSourceTrackingService', () => {
7676
const username = 'foo@bar.com';
77-
let orgId: string;
7877
const $$ = instantiateContext();
7978
let remoteSourceTrackingService: RemoteSourceTrackingService;
8079

@@ -97,15 +96,14 @@ describe('remoteSourceTrackingService', () => {
9796
sourceMembers: Object.fromEntries(remoteSourceTrackingService.sourceMembers),
9897
});
9998

100-
afterEach(async () => {
101-
await RemoteSourceTrackingService.delete(orgId);
99+
afterEach(() => {
100+
RemoteSourceTrackingService['instanceMap'].clear();
102101
restoreContext($$);
103102
});
104103

105104
beforeEach(async () => {
106105
stubContext($$);
107106
const orgData = new MockTestOrgData();
108-
orgId = orgData.orgId;
109107
orgData.username = username;
110108
orgData.tracksSource = true;
111109
await $$.stubAuths(orgData);
@@ -115,57 +113,57 @@ describe('remoteSourceTrackingService', () => {
115113
org,
116114
projectPath: await $$.localPathRetriever($$.id),
117115
});
116+
});
118117

119-
describe('remoteChangeElementToChangeResult()', () => {
120-
const memberIdOrName = '00eO4000003cP5J';
121-
it('should return correct ChangeResult for EmailTemplateFolder', () => {
122-
const rce: RemoteChangeElement = {
123-
name: 'level1/level2/level3',
124-
type: 'EmailTemplateFolder',
125-
deleted: false,
126-
modified: true,
127-
changedBy: 'Shelby McLaughlin',
128-
revisionCounter: 1,
129-
memberIdOrName,
130-
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
131-
};
132-
const changeResult = remoteChangeElementToChangeResult(rce);
133-
expect(changeResult).to.deep.equal({
134-
origin: 'remote',
135-
name: 'level1/level2/level3',
136-
type: 'EmailFolder',
137-
deleted: false,
138-
modified: true,
139-
changedBy: 'Shelby McLaughlin',
140-
revisionCounter: 1,
141-
memberIdOrName,
142-
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
143-
});
118+
describe('remoteChangeElementToChangeResult()', () => {
119+
const memberIdOrName = '00eO4000003cP5J';
120+
it('should return correct ChangeResult for EmailTemplateFolder', () => {
121+
const rce: RemoteChangeElement = {
122+
name: 'level1/level2/level3',
123+
type: 'EmailTemplateFolder',
124+
deleted: false,
125+
modified: true,
126+
changedBy: 'Shelby McLaughlin',
127+
revisionCounter: 1,
128+
memberIdOrName,
129+
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
130+
};
131+
const changeResult = remoteChangeElementToChangeResult(rce);
132+
expect(changeResult).to.deep.equal({
133+
origin: 'remote',
134+
name: 'level1/level2/level3',
135+
type: 'EmailFolder',
136+
deleted: false,
137+
modified: true,
138+
changedBy: 'Shelby McLaughlin',
139+
revisionCounter: 1,
140+
memberIdOrName,
141+
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
144142
});
143+
});
145144

146-
it('should return correct ChangeResult for LightningComponentResource', () => {
147-
const rce: RemoteChangeElement = {
148-
name: 'fooLWC/bar',
149-
type: 'LightningComponentResource',
150-
deleted: false,
151-
modified: true,
152-
changedBy: 'Shelby McLaughlin',
153-
revisionCounter: 1,
154-
memberIdOrName,
155-
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
156-
};
157-
const changeResult = remoteChangeElementToChangeResult(rce);
158-
expect(changeResult).to.deep.equal({
159-
origin: 'remote',
160-
name: 'fooLWC',
161-
type: 'LightningComponentBundle',
162-
deleted: false,
163-
modified: true,
164-
changedBy: 'Shelby McLaughlin',
165-
revisionCounter: 1,
166-
memberIdOrName,
167-
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
168-
});
145+
it('should return correct ChangeResult for LightningComponentResource', () => {
146+
const rce: RemoteChangeElement = {
147+
name: 'fooLWC/bar',
148+
type: 'LightningComponentResource',
149+
deleted: false,
150+
modified: true,
151+
changedBy: 'Shelby McLaughlin',
152+
revisionCounter: 1,
153+
memberIdOrName,
154+
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
155+
};
156+
const changeResult = remoteChangeElementToChangeResult(rce);
157+
expect(changeResult).to.deep.equal({
158+
origin: 'remote',
159+
name: 'fooLWC',
160+
type: 'LightningComponentBundle',
161+
deleted: false,
162+
modified: true,
163+
changedBy: 'Shelby McLaughlin',
164+
revisionCounter: 1,
165+
memberIdOrName,
166+
lastModifiedDate: defaultSourceMemberValues.LastModifiedDate,
169167
});
170168
});
171169
});
@@ -178,6 +176,50 @@ describe('remoteSourceTrackingService', () => {
178176
});
179177
});
180178

179+
describe('getInstance', () => {
180+
const org2Dir = resolve('temp2');
181+
const org3Dir = resolve('temp3');
182+
183+
it('should purge old instances when they exceed MAX_INSTANCE_CACHE_TTL', async () => {
184+
// Create second instance
185+
const org2 = await Org.create({ aliasOrUsername: 'org2@test.com' });
186+
$$.SANDBOX.stub(org2, 'getOrgId').returns('00D999999999999999');
187+
await RemoteSourceTrackingService.getInstance({
188+
org: org2,
189+
projectPath: org2Dir,
190+
});
191+
192+
// Verify both instances exist
193+
expect(RemoteSourceTrackingService['instanceMap'].size).to.equal(2);
194+
195+
// Get a date that is over 1 hour from now
196+
const MAX_INSTANCE_CACHE_TTL = 1000 * 60 * 60 * 1; // 1 hour
197+
const futureDateNow = Date.now() + MAX_INSTANCE_CACHE_TTL + 1000;
198+
199+
// Stub Date.now to simulate time passing
200+
const dateNowStub = $$.SANDBOX.stub(Date, 'now').returns(futureDateNow);
201+
202+
// Create a new instance which should trigger purge
203+
const org3 = await Org.create({ aliasOrUsername: 'org3@test.com' });
204+
$$.SANDBOX.stub(org3, 'getOrgId').returns('00D777777777777777');
205+
await RemoteSourceTrackingService.getInstance({
206+
org: org3,
207+
projectPath: org3Dir,
208+
});
209+
210+
// Verify old instances were purged
211+
expect(RemoteSourceTrackingService['instanceMap'].size).to.equal(1);
212+
expect(RemoteSourceTrackingService['instanceMap'].get('00D777777777777777')).to.be.an('object');
213+
expect(dateNowStub.called).to.equal(true);
214+
});
215+
216+
afterEach(() => {
217+
// clean up the tracking files created by the tests
218+
rmSync(org2Dir, { recursive: true });
219+
rmSync(org3Dir, { recursive: true });
220+
});
221+
});
222+
181223
describe('init', () => {
182224
it('should set initial state of contents', async () => {
183225
const queryMembersFromSpy = $$.SANDBOX.spy(orgQueryMocks, 'querySourceMembersFrom');

0 commit comments

Comments
 (0)