From 65a99fb94f8bc4c7f67b2bb1edf9df6ba8c2c80b Mon Sep 17 00:00:00 2001 From: Sergei Zharinov <zharinov@users.noreply.github.com> Date: Wed, 6 Apr 2022 19:00:26 +0300 Subject: [PATCH] feat(github): Implement ApiCache data structure (#14943) --- lib/modules/platform/github/api-cache.spec.ts | 162 ++++++++++++++++++ lib/modules/platform/github/api-cache.ts | 84 +++++++++ lib/modules/platform/github/types.ts | 14 ++ 3 files changed, 260 insertions(+) create mode 100644 lib/modules/platform/github/api-cache.spec.ts create mode 100644 lib/modules/platform/github/api-cache.ts diff --git a/lib/modules/platform/github/api-cache.spec.ts b/lib/modules/platform/github/api-cache.spec.ts new file mode 100644 index 0000000000..d0c554c585 --- /dev/null +++ b/lib/modules/platform/github/api-cache.spec.ts @@ -0,0 +1,162 @@ +import { DateTime } from 'luxon'; +import { ApiCache } from './api-cache'; + +describe('modules/platform/github/api-cache', () => { + const now = DateTime.fromISO('2000-01-01T00:00:00.000+00:00'); + const t1 = now.plus({ years: 1 }).toISO(); + const t1_http = now.plus({ years: 1 }).toHTTP(); + + const t2 = now.plus({ years: 2 }).toISO(); + const t2_http = now.plus({ years: 2 }).toHTTP(); + + const t3 = now.plus({ years: 3 }).toISO(); + const t3_http = now.plus({ years: 3 }).toHTTP(); + + const t4 = now.plus({ years: 4 }).toISO(); + const t4_http = now.plus({ years: 4 }).toHTTP(); + + const t5 = now.plus({ years: 5 }).toISO(); + const t5_http = now.plus({ years: 5 }).toHTTP(); + + it('stores and retrieves items', () => { + const item1 = { number: 1, updated_at: t1 }; + const item2 = { number: 2, updated_at: t2 }; + const apiCache = new ApiCache({ + items: { 1: item1 }, + lastModified: t1, + }); + + expect(apiCache.getItem(1)).toBe(item1); + expect(apiCache.getItem(2)).toBeNull(); + + apiCache.updateItem(item2); + expect(apiCache.getItem(2)).toBe(item2); + expect(apiCache.lastModified).toBe(t1_http); // Not `t2`, see jsdoc for `setItem` + expect(apiCache.getItems()).toEqual([item1, item2]); + }); + + describe('reconcile', () => { + it('appends new items', () => { + const apiCache = new ApiCache({ items: {} }); + expect(apiCache.lastModified).toBeNull(); + + const res1 = apiCache.reconcile([ + { number: 2, updated_at: t2 }, + { number: 1, updated_at: t1 }, + ]); + expect(apiCache.lastModified).toBe(t2_http); + expect(res1).toBeTrue(); + + const res2 = apiCache.reconcile([ + { number: 4, updated_at: t4 }, + { number: 3, updated_at: t3 }, + ]); + expect(apiCache.lastModified).toBe(t4_http); + expect(res2).toBeTrue(); + + expect(apiCache.getItems()).toEqual([ + { number: 1, updated_at: t1 }, + { number: 2, updated_at: t2 }, + { number: 3, updated_at: t3 }, + { number: 4, updated_at: t4 }, + ]); + }); + + it('handles updated items', () => { + const apiCache = new ApiCache({ + items: { + 1: { number: 1, updated_at: t1 }, + 2: { number: 2, updated_at: t2 }, + 3: { number: 3, updated_at: t3 }, + }, + lastModified: t3, + }); + + const needNextPage = apiCache.reconcile([ + { number: 1, updated_at: t5 }, + { number: 2, updated_at: t4 }, + { number: 3, updated_at: t3 }, + ]); + + expect(apiCache.getItems()).toEqual([ + { number: 1, updated_at: t5 }, + { number: 2, updated_at: t4 }, + { number: 3, updated_at: t3 }, + ]); + expect(apiCache.lastModified).toBe(t5_http); + expect(needNextPage).toBeFalse(); + }); + + it('ignores page overlap', () => { + const apiCache = new ApiCache({ + items: {}, + }); + + const res1 = apiCache.reconcile([ + { number: 5, updated_at: t5 }, + { number: 4, updated_at: t4 }, + { number: 3, updated_at: t3 }, + ]); + const res2 = apiCache.reconcile([ + { number: 3, updated_at: t3 }, + { number: 2, updated_at: t2 }, + { number: 1, updated_at: t1 }, + ]); + + expect(apiCache.getItems()).toEqual([ + { number: 1, updated_at: t1 }, + { number: 2, updated_at: t2 }, + { number: 3, updated_at: t3 }, + { number: 4, updated_at: t4 }, + { number: 5, updated_at: t5 }, + ]); + expect(apiCache.lastModified).toBe(t5_http); + expect(res1).toBeTrue(); + expect(res2).toBeTrue(); + }); + + it('does not require new page if all items are old', () => { + const apiCache = new ApiCache({ + items: { + 1: { number: 1, updated_at: t1 }, + 2: { number: 2, updated_at: t2 }, + 3: { number: 3, updated_at: t3 }, + }, + lastModified: t3, + }); + + const needNextPage = apiCache.reconcile([ + { number: 3, updated_at: t3 }, + { number: 2, updated_at: t2 }, + { number: 1, updated_at: t1 }, + ]); + + expect(apiCache.getItems()).toEqual([ + { number: 1, updated_at: t1 }, + { number: 2, updated_at: t2 }, + { number: 3, updated_at: t3 }, + ]); + expect(apiCache.lastModified).toBe(t3_http); + expect(needNextPage).toBeFalse(); + }); + }); + + describe('etag', () => { + it('returns null', () => { + const apiCache = new ApiCache({ items: {} }); + expect(apiCache.etag).toBeNull(); + }); + + it('sets and retrieves non-null value', () => { + const apiCache = new ApiCache({ items: {} }); + apiCache.etag = 'foobar'; + expect(apiCache.etag).toBe('foobar'); + }); + + it('deletes value for null parameter', () => { + const apiCache = new ApiCache({ items: {} }); + apiCache.etag = null; + expect(apiCache.etag).toBeNull(); + }); + }); +}); diff --git a/lib/modules/platform/github/api-cache.ts b/lib/modules/platform/github/api-cache.ts new file mode 100644 index 0000000000..09d4fed91c --- /dev/null +++ b/lib/modules/platform/github/api-cache.ts @@ -0,0 +1,84 @@ +import { DateTime } from 'luxon'; +import type { ApiPageCache, ApiPageItem } from './types'; + +export class ApiCache<T extends ApiPageItem> { + constructor(private cache: ApiPageCache<T>) {} + + get etag(): string | null { + return this.cache.etag ?? null; + } + + set etag(value: string | null) { + if (value === null) { + delete this.cache.etag; + } else { + this.cache.etag = value; + } + } + + /** + * @returns Date formatted to use in HTTP headers + */ + get lastModified(): string | null { + const { lastModified } = this.cache; + return lastModified ? DateTime.fromISO(lastModified).toHTTP() : null; + } + + getItems(): T[] { + return Object.values(this.cache.items); + } + + getItem(number: number): T | null { + return this.cache.items[number] ?? null; + } + + /** + * It intentionally doesn't alter `lastModified` cache field. + * + * The point is to allow cache modifications during run, but + * force fetching and refreshing of modified items next run. + */ + updateItem(item: T): void { + this.cache.items[item.number] = item; + } + + /** + * Copies items from `page` to `cache`. + * Updates internal cache timestamp. + * + * @param cache Cache object + * @param page List of cacheable items, sorted by `updated_at` field + * starting from the most recently updated. + * @returns `true` when the next page is likely to contain fresh items, + * otherwise `false`. + */ + reconcile(page: T[]): boolean { + const { items } = this.cache; + let { lastModified } = this.cache; + + let needNextPage = true; + + for (const newItem of page) { + const number = newItem.number; + const oldItem = items[number]; + + const itemNewTime = DateTime.fromISO(newItem.updated_at); + const itemOldTime = oldItem?.updated_at + ? DateTime.fromISO(oldItem.updated_at) + : null; + + items[number] = newItem; + + needNextPage = itemOldTime ? itemOldTime < itemNewTime : true; + + const cacheOldTime = lastModified ? DateTime.fromISO(lastModified) : null; + if (!cacheOldTime || itemNewTime > cacheOldTime) { + lastModified = newItem.updated_at; + } + } + + this.cache.lastModified = lastModified; + + return needNextPage; + } +} diff --git a/lib/modules/platform/github/types.ts b/lib/modules/platform/github/types.ts index 46114f32e2..49e116295c 100644 --- a/lib/modules/platform/github/types.ts +++ b/lib/modules/platform/github/types.ts @@ -125,3 +125,17 @@ export interface GhAutomergeResponse { pullRequest: { number: number }; }; } + +export interface ApiPageItem { + number: number; + updated_at: string; +} + +/** + * Mutable object designed to be used in the repository cache + */ +export interface ApiPageCache<T extends ApiPageItem = ApiPageItem> { + items: Record<number, T>; + lastModified?: string; + etag?: string; +} -- GitLab