108915: Always invalidate all followLinks when invalidating linked cached object

This commit is contained in:
Alexandre Vryghem
2023-12-08 17:05:00 +01:00
parent e339b46228
commit 24eb5b4bc0
9 changed files with 97 additions and 53 deletions

View File

@@ -21,6 +21,8 @@ import { RequestEntryState } from '../request-entry-state.model';
import { fakeAsync, tick } from '@angular/core/testing'; import { fakeAsync, tick } from '@angular/core/testing';
import { BaseDataService } from './base-data.service'; import { BaseDataService } from './base-data.service';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
const endpoint = 'https://rest.api/core'; const endpoint = 'https://rest.api/core';
@@ -46,7 +48,7 @@ describe('BaseDataService', () => {
let requestService; let requestService;
let halService; let halService;
let rdbService; let rdbService;
let objectCache; let objectCache: ObjectCacheServiceStub;
let selfLink; let selfLink;
let linksToFollow; let linksToFollow;
let testScheduler; let testScheduler;
@@ -56,24 +58,7 @@ describe('BaseDataService', () => {
requestService = getMockRequestService(); requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any; halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
objectCache = { objectCache = new ObjectCacheServiceStub();
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
},
addDependency: () => {
/* empty */
},
removeDependents: () => {
/* empty */
},
} as any;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [ linksToFollow = [
followLink('a'), followLink('a'),
@@ -104,7 +89,7 @@ describe('BaseDataService', () => {
return new TestService( return new TestService(
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache as ObjectCacheService,
halService, halService,
); );
} }
@@ -567,7 +552,7 @@ describe('BaseDataService', () => {
getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
requestUUIDs: ['request1', 'request2', 'request3'], requestUUIDs: ['request1', 'request2', 'request3'],
dependentRequestUUIDs: ['request4', 'request5'] dependentRequestUUIDs: ['request4', 'request5']
})); } as ObjectCacheEntry));
}); });

View File

@@ -24,6 +24,7 @@ import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { ObjectCacheService } from '../../cache/object-cache.service'; import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALDataService } from './hal-data-service.interface'; import { HALDataService } from './hal-data-service.interface';
import { getFirstCompletedRemoteData } from '../../shared/operators'; import { getFirstCompletedRemoteData } from '../../shared/operators';
import { HALLink } from '../../shared/hal-link.model';
export const EMBED_SEPARATOR = '%2F'; export const EMBED_SEPARATOR = '%2F';
/** /**
@@ -268,7 +269,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe( const response$: Observable<RemoteData<T>> = this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a // This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request // call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
@@ -277,6 +278,22 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.reRequestStaleRemoteData(reRequestOnStale, () => this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
); );
return response$.pipe(
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
tap((remoteDataObject: RemoteData<T>) => {
if (hasValue(remoteDataObject?.payload?._links)) {
for (const followLink of Object.values(remoteDataObject.payload._links)) {
// followLink can be either an individual HALLink or a HALLink[]
const followLinksList: HALLink[] = [].concat(followLink);
for (const individualFollowLink of followLinksList) {
if (hasValue(individualFollowLink?.href)) {
this.addDependency(response$, individualFollowLink.href);
}
}
}
}
}),
);
} }
/** /**
@@ -302,7 +319,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe( const response$: Observable<RemoteData<PaginatedList<T>>> = this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a // This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request // call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
@@ -311,6 +328,26 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
this.reRequestStaleRemoteData(reRequestOnStale, () => this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
); );
return response$.pipe(
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
tap((remoteDataObject: RemoteData<PaginatedList<T>>) => {
if (hasValue(remoteDataObject?.payload?.page)) {
for (const object of remoteDataObject.payload.page) {
if (hasValue(object?._links)) {
for (const followLink of Object.values(object._links)) {
// followLink can be either an individual HALLink or a HALLink[]
const followLinksList: HALLink[] = [].concat(followLink);
for (const individualFollowLink of followLinksList) {
if (hasValue(individualFollowLink?.href)) {
this.addDependency(response$, individualFollowLink.href);
}
}
}
}
}
}
}),
);
} }
/** /**

View File

@@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testSearchDataImplementation } from './base/search-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
const url = 'fake-url'; const url = 'fake-url';
const collectionId = 'fake-collection-id'; const collectionId = 'fake-collection-id';
@@ -35,7 +36,7 @@ describe('CollectionDataService', () => {
let translate: TranslateService; let translate: TranslateService;
let notificationsService: any; let notificationsService: any;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService; let objectCache: ObjectCacheServiceStub;
let halService: any; let halService: any;
const mockCollection1: Collection = Object.assign(new Collection(), { const mockCollection1: Collection = Object.assign(new Collection(), {
@@ -205,14 +206,12 @@ describe('CollectionDataService', () => {
buildFromRequestUUID: buildResponse$, buildFromRequestUUID: buildResponse$,
buildSingle: buildResponse$ buildSingle: buildResponse$
}); });
objectCache = jasmine.createSpyObj('objectCache', { objectCache = new ObjectCacheServiceStub();
remove: jasmine.createSpy('remove')
});
halService = new HALEndpointServiceStub(url); halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub(); notificationsService = new NotificationsServiceStub();
translate = getMockTranslateService(); translate = getMockTranslateService();
service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate);
} }
}); });

View File

@@ -23,6 +23,7 @@ import { FindListOptions } from './find-list-options.model';
import { testSearchDataImplementation } from './base/search-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec';
import { MetadataValue } from '../shared/metadata.models'; import { MetadataValue } from '../shared/metadata.models';
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model'; import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('RelationshipDataService', () => { describe('RelationshipDataService', () => {
let service: RelationshipDataService; let service: RelationshipDataService;
@@ -114,14 +115,7 @@ describe('RelationshipDataService', () => {
'href': buildList$, 'href': buildList$,
'https://rest.api/core/publication/relationships': relationships$ 'https://rest.api/core/publication/relationships': relationships$
}); });
const objectCache = Object.assign({ const objectCache = new ObjectCacheServiceStub();
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
remove: () => {
},
hasBySelfLinkObservable: () => observableOf(false),
hasByHref$: () => observableOf(false)
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
}) as ObjectCacheService;
const itemService = jasmine.createSpyObj('itemService', { const itemService = jasmine.createSpyObj('itemService', {
findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)), findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)),
@@ -133,7 +127,7 @@ describe('RelationshipDataService', () => {
requestService, requestService,
rdbService, rdbService,
halService, halService,
objectCache, objectCache as ObjectCacheService,
itemService, itemService,
null, null,
jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v), jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v),

View File

@@ -10,6 +10,7 @@ import { RequestService } from './request.service';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { hasValueOperator } from '../../shared/empty.util'; import { hasValueOperator } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('RelationshipTypeDataService', () => { describe('RelationshipTypeDataService', () => {
let service: RelationshipTypeDataService; let service: RelationshipTypeDataService;
@@ -28,7 +29,7 @@ describe('RelationshipTypeDataService', () => {
let buildList; let buildList;
let rdbService; let rdbService;
let objectCache; let objectCache: ObjectCacheServiceStub;
function init() { function init() {
restEndpointURL = 'https://rest.api/relationshiptypes'; restEndpointURL = 'https://rest.api/relationshiptypes';
@@ -60,21 +61,14 @@ describe('RelationshipTypeDataService', () => {
buildList = createSuccessfulRemoteDataObject(createPaginatedList([relationshipType1, relationshipType2])); buildList = createSuccessfulRemoteDataObject(createPaginatedList([relationshipType1, relationshipType2]));
rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList)); rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList));
objectCache = Object.assign({ objectCache = new ObjectCacheServiceStub();
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
remove: () => {
},
hasBySelfLinkObservable: () => observableOf(false)
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
}) as ObjectCacheService;
} }
function initTestService() { function initTestService() {
return new RelationshipTypeDataService( return new RelationshipTypeDataService(
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache as ObjectCacheService,
halService, halService,
); );
} }

View File

@@ -20,13 +20,14 @@ import { FindListOptions } from '../data/find-list-options.model';
import { EPersonDataService } from '../eperson/eperson-data.service'; import { EPersonDataService } from '../eperson/eperson-data.service';
import { GroupDataService } from '../eperson/group-data.service'; import { GroupDataService } from '../eperson/group-data.service';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('ResourcePolicyService', () => { describe('ResourcePolicyService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: ResourcePolicyDataService; let service: ResourcePolicyDataService;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService; let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService; let halService: HALEndpointService;
let responseCacheEntry: RequestEntry; let responseCacheEntry: RequestEntry;
let ePersonService: EPersonDataService; let ePersonService: EPersonDataService;
@@ -139,14 +140,14 @@ describe('ResourcePolicyService', () => {
a: 'https://rest.api/rest/api/eperson/groups/' + groupUUID a: 'https://rest.api/rest/api/eperson/groups/' + groupUUID
}), }),
}); });
objectCache = {} as ObjectCacheService; objectCache = new ObjectCacheServiceStub();
const notificationsService = {} as NotificationsService; const notificationsService = {} as NotificationsService;
const comparator = {} as any; const comparator = {} as any;
service = new ResourcePolicyDataService( service = new ResourcePolicyDataService(
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache as ObjectCacheService,
halService, halService,
notificationsService, notificationsService,
comparator, comparator,

View File

@@ -25,6 +25,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestEntry } from '../../data/request-entry.model'; import { RequestEntry } from '../../data/request-entry.model';
import { VocabularyDataService } from './vocabulary.data.service'; import { VocabularyDataService } from './vocabulary.data.service';
import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service'; import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service';
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
describe('VocabularyService', () => { describe('VocabularyService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -205,6 +206,7 @@ describe('VocabularyService', () => {
function initTestService() { function initTestService() {
hrefOnlyDataService = getMockHrefOnlyDataService(); hrefOnlyDataService = getMockHrefOnlyDataService();
objectCache = new ObjectCacheServiceStub() as ObjectCacheService;
return new VocabularyService( return new VocabularyService(
requestService, requestService,

View File

@@ -17,13 +17,14 @@ import { RestResponse } from '../cache/response.models';
import { RequestEntry } from '../data/request-entry.model'; import { RequestEntry } from '../data/request-entry.model';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { GroupDataService } from '../eperson/group-data.service'; import { GroupDataService } from '../eperson/group-data.service';
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
describe('SupervisionOrderService', () => { describe('SupervisionOrderService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: SupervisionOrderDataService; let service: SupervisionOrderDataService;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService; let objectCache: ObjectCacheServiceStub;
let halService: HALEndpointService; let halService: HALEndpointService;
let responseCacheEntry: RequestEntry; let responseCacheEntry: RequestEntry;
let groupService: GroupDataService; let groupService: GroupDataService;
@@ -127,14 +128,14 @@ describe('SupervisionOrderService', () => {
a: 'https://rest.api/rest/api/group/groups/' + groupUUID a: 'https://rest.api/rest/api/group/groups/' + groupUUID
}), }),
}); });
objectCache = {} as ObjectCacheService; objectCache = new ObjectCacheServiceStub();
const notificationsService = {} as NotificationsService; const notificationsService = {} as NotificationsService;
const comparator = {} as any; const comparator = {} as any;
service = new SupervisionOrderDataService( service = new SupervisionOrderDataService(
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache as ObjectCacheService,
halService, halService,
notificationsService, notificationsService,
comparator, comparator,

View File

@@ -0,0 +1,31 @@
import { Observable, of as observableOf } from 'rxjs';
import { CacheableObject } from '../../core/cache/cacheable-object.model';
import { ObjectCacheEntry } from '../../core/cache/object-cache.reducer';
/* eslint-disable @typescript-eslint/no-empty-function */
/**
* Stub class of {@link ObjectCacheService}
*/
export class ObjectCacheServiceStub {
add(_object: CacheableObject, _msToLive: number, _requestUUID: string, _alternativeLink?: string): void {
}
remove(_href: string): void {
}
getByHref(_href: string): Observable<ObjectCacheEntry> {
return observableOf(undefined);
}
hasByHref$(_href: string): Observable<boolean> {
return observableOf(false);
}
addDependency(_href$: string | Observable<string>, _dependsOnHref$: string | Observable<string>): void {
}
removeDependents(_href: string): void {
}
}