* добавлен ендпоинт find-from-root - для рекурсивной загрузки подзадач от корневой задачи * добавлен ендпоинт find-from-merged-trees-and-query - для гибридной загрузки задач от корневых + с помощью find запроса для couchdb
225 lines
6.9 KiB
TypeScript
225 lines
6.9 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-namespace */
|
|
import { RedmineTypes } from '../models/redmine-types';
|
|
import { CacheTTL, Injectable, Logger } from '@nestjs/common';
|
|
import { Issues } from '../couchdb-datasources/issues';
|
|
import { RedmineEventsGateway } from '../events/redmine-events.gateway';
|
|
import { RedmineIssuesCacheWriterService } from '../issue-cache-writer/redmine-issues-cache-writer.service';
|
|
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
|
|
import { MemoryCache } from '../utils/memory-cache';
|
|
import nano from 'nano';
|
|
import { UNLIMITED } from '../consts/consts';
|
|
import { GetParentsHint } from '../utils/get-parents-hint';
|
|
import { TreeIssuesStore } from '../utils/tree-issues-store';
|
|
import { FlatIssuesStore } from '../utils/flat-issues-store';
|
|
|
|
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
|
|
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
|
|
|
|
export namespace IssuesServiceNs {
|
|
export type IssuesLoader = (
|
|
ids: number[],
|
|
) => Promise<Record<number, RedmineTypes.Issue | null>>;
|
|
|
|
export type TreesAndQuery = {
|
|
rootIds?: number[];
|
|
rootIssues?: RedmineTypes.Issue[];
|
|
query?: nano.MangoQuery;
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class IssuesService {
|
|
private logger = new Logger(IssuesService.name);
|
|
|
|
private memoryCache = new MemoryCache<number, RedmineTypes.Issue>(
|
|
ISSUE_MEMORY_CACHE_LIFETIME,
|
|
ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL,
|
|
);
|
|
|
|
constructor(
|
|
private redmineDataLoader: RedmineDataLoader,
|
|
private issues: Issues,
|
|
private redmineIssuesCacheWriterService: RedmineIssuesCacheWriterService,
|
|
private redmineEventsGateway: RedmineEventsGateway,
|
|
) {}
|
|
|
|
async find(query: nano.MangoQuery): Promise<RedmineTypes.Issue[]> {
|
|
const issueDb = await this.issues.getDatasource();
|
|
const res = await issueDb.find(query);
|
|
return res.docs;
|
|
}
|
|
|
|
@CacheTTL(60)
|
|
async getIssues(ids: number[]): Promise<RedmineTypes.Issue[]> {
|
|
const issueDb = await this.issues.getDatasource();
|
|
try {
|
|
const docs = await issueDb.find({
|
|
selector: {
|
|
id: {
|
|
$in: ids,
|
|
},
|
|
},
|
|
limit: UNLIMITED,
|
|
});
|
|
return docs.docs;
|
|
} catch (ex) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
@CacheTTL(120)
|
|
async getParents(
|
|
issueId: number,
|
|
count?: number,
|
|
): Promise<RedmineTypes.Issue[]> {
|
|
let index = 0;
|
|
const res: RedmineTypes.Issue[] = [];
|
|
let currentIssue = await this.getIssue(issueId);
|
|
res.unshift(currentIssue);
|
|
let parentIssueId = currentIssue.parent?.id || null;
|
|
while (
|
|
parentIssueId !== null &&
|
|
currentIssue.id >= 0 &&
|
|
(typeof count === 'undefined' || count === null || index < count)
|
|
) {
|
|
currentIssue = await this.getIssue(parentIssueId);
|
|
res.unshift(currentIssue);
|
|
parentIssueId = currentIssue.parent?.id || null;
|
|
index++;
|
|
}
|
|
const parentsHint = GetParentsHint(res);
|
|
this.logger.debug(`Parents for issue #${issueId} - ${parentsHint}`);
|
|
return res;
|
|
}
|
|
|
|
async getIssue(
|
|
issueId: number,
|
|
force = false,
|
|
): Promise<RedmineTypes.Issue | null> {
|
|
const issueFromMemoryCache = this.getIssueFromMemoryCache(issueId);
|
|
if (issueFromMemoryCache) {
|
|
return issueFromMemoryCache;
|
|
}
|
|
const issueFromCache = await this.getIssueFromCache(issueId);
|
|
if (issueFromCache) {
|
|
this.memoryCache.set(issueId, issueFromCache);
|
|
return issueFromCache;
|
|
}
|
|
if (force) {
|
|
// force = true - прямо из redmine
|
|
const issueFromRedmine = await this.redmineDataLoader.loadIssue(issueId);
|
|
if (issueFromRedmine) {
|
|
await this.redmineIssuesCacheWriterService.saveIssue(issueFromRedmine);
|
|
this.memoryCache.set(issueId, issueFromRedmine);
|
|
return issueFromRedmine;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
// force = false - через очередь
|
|
this.redmineEventsGateway.addIssues([issueId]);
|
|
const unknownIssue = { ...RedmineTypes.Unknown.issue };
|
|
this.memoryCache.set(issueId, unknownIssue);
|
|
return unknownIssue;
|
|
}
|
|
}
|
|
|
|
getIssueFromMemoryCache(issueId: number): RedmineTypes.Issue | null {
|
|
const issue = this.memoryCache.get(issueId);
|
|
if (issue) {
|
|
this.logger.debug(
|
|
`Get issue from memory cache: id = ${issueId}, subject = ${issue.subject}`,
|
|
);
|
|
}
|
|
return issue;
|
|
}
|
|
|
|
async getIssueFromRedmine(
|
|
issueId: number,
|
|
): Promise<RedmineTypes.Issue | null> {
|
|
const issue = await this.redmineDataLoader.loadIssue(issueId);
|
|
if (issue) {
|
|
this.logger.debug(
|
|
`Get issue from redmine: id = ${issueId}, subject = ${issue.subject}`,
|
|
);
|
|
}
|
|
return issue;
|
|
}
|
|
|
|
async getIssueFromCache(issueId: number): Promise<RedmineTypes.Issue | null> {
|
|
const issueDb = await this.issues.getDatasource();
|
|
try {
|
|
const issue = (await issueDb.get(String(issueId))) as any;
|
|
this.logger.debug(
|
|
`Get issue from couchdb: id = ${issueId}, subject = ${issue.subject}`,
|
|
);
|
|
return issue;
|
|
} catch (ex) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
createDynamicIssuesLoader(): IssuesServiceNs.IssuesLoader {
|
|
const fn = async (
|
|
ids: number[],
|
|
): Promise<Record<number, RedmineTypes.Issue>> => {
|
|
const issues = await this.getIssues(ids);
|
|
const res = {} as Record<number, RedmineTypes.Issue | null>;
|
|
for (let i = 0; i < issues.length; i++) {
|
|
const issue = issues[i];
|
|
res[issue.id] = issue;
|
|
}
|
|
return res;
|
|
};
|
|
return fn;
|
|
}
|
|
|
|
async getIssuesWithChildren(
|
|
rootIssue: RedmineTypes.Issue,
|
|
): Promise<RedmineTypes.Issue[]> {
|
|
const treeIssuesStore = new TreeIssuesStore();
|
|
treeIssuesStore.setRootIssue(rootIssue);
|
|
const loader = this.createDynamicIssuesLoader();
|
|
await treeIssuesStore.fillData(loader);
|
|
return treeIssuesStore.getIssuesWithChildren();
|
|
}
|
|
|
|
async getIssuesFromRoot(
|
|
rootIssue: RedmineTypes.Issue,
|
|
): Promise<RedmineTypes.Issue[]> {
|
|
const treeStore = new TreeIssuesStore();
|
|
treeStore.setRootIssue(rootIssue);
|
|
const loader = this.createDynamicIssuesLoader();
|
|
await treeStore.fillData(loader);
|
|
return treeStore.getFlatStore().getIssues();
|
|
}
|
|
|
|
async mergedTreesAndFind(
|
|
query: IssuesServiceNs.TreesAndQuery,
|
|
): Promise<RedmineTypes.Issue[]> {
|
|
const flatStore = new FlatIssuesStore();
|
|
const loader = this.createDynamicIssuesLoader();
|
|
if (query.query) {
|
|
const issues = await this.find(query.query);
|
|
issues.forEach((issue) => flatStore.push(issue));
|
|
}
|
|
|
|
const rootIssues = [];
|
|
if (query.rootIds) {
|
|
const issues = await this.getIssues(query.rootIds);
|
|
rootIssues.push(...issues);
|
|
}
|
|
if (query.rootIssues) {
|
|
rootIssues.push(...query.rootIssues);
|
|
}
|
|
for (let i = 0; i < rootIssues.length; i++) {
|
|
const rootIssue = rootIssues[i];
|
|
const issues = await this.getIssuesFromRoot(rootIssue);
|
|
issues.forEach((issue) => flatStore.push(issue));
|
|
}
|
|
|
|
await flatStore.fillData(loader);
|
|
|
|
return flatStore.getIssues();
|
|
}
|
|
}
|