Replace MessageController with MessageCache
This commit is contained in:
parent
ba1a8aad09
commit
7d35216fda
73 changed files with 2237 additions and 1229 deletions
410
ts/services/MessageCache.ts
Normal file
410
ts/services/MessageCache.ts
Normal file
|
@ -0,0 +1,410 @@
|
|||
// Copyright 2019 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import type { MessageAttributesType } from '../model-types.d';
|
||||
import type { MessageModel } from '../models/messages';
|
||||
import * as Errors from '../types/errors';
|
||||
import * as log from '../logging/log';
|
||||
import { drop } from '../util/drop';
|
||||
import { getEnvironment, Environment } from '../environment';
|
||||
import { getMessageConversation } from '../util/getMessageConversation';
|
||||
import { getMessageModelLogger } from '../util/MessageModelLogger';
|
||||
import { getSenderIdentifier } from '../util/getSenderIdentifier';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
import { map } from '../util/iterables';
|
||||
import { softAssert, strictAssert } from '../util/assert';
|
||||
|
||||
export class MessageCache {
|
||||
private state = {
|
||||
messages: new Map<string, MessageAttributesType>(),
|
||||
messageIdsBySender: new Map<string, string>(),
|
||||
messageIdsBySentAt: new Map<number, Array<string>>(),
|
||||
lastAccessedAt: new Map<string, number>(),
|
||||
};
|
||||
|
||||
// Stores the models so that __DEPRECATED$register always returns the existing
|
||||
// copy instead of a new model.
|
||||
private modelCache = new Map<string, MessageModel>();
|
||||
|
||||
// Synchronously access a message's attributes from internal cache. Will
|
||||
// return undefined if the message does not exist in memory.
|
||||
public accessAttributes(
|
||||
messageId: string
|
||||
): Readonly<MessageAttributesType> | undefined {
|
||||
const messageAttributes = this.state.messages.get(messageId);
|
||||
return messageAttributes
|
||||
? this.freezeAttributes(messageAttributes)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Synchronously access a message's attributes from internal cache. Throws
|
||||
// if the message does not exist in memory.
|
||||
public accessAttributesOrThrow(
|
||||
source: string,
|
||||
messageId: string
|
||||
): Readonly<MessageAttributesType> {
|
||||
const messageAttributes = this.accessAttributes(messageId);
|
||||
strictAssert(
|
||||
messageAttributes,
|
||||
`MessageCache.accessAttributesOrThrow/${source}: no message`
|
||||
);
|
||||
return messageAttributes;
|
||||
}
|
||||
|
||||
// Evicts messages from the message cache if they have not been accessed past
|
||||
// the expiry time.
|
||||
public deleteExpiredMessages(expiryTime: number): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [messageId, messageAttributes] of this.state.messages) {
|
||||
const timeLastAccessed = this.state.lastAccessedAt.get(messageId) ?? 0;
|
||||
const conversation = getMessageConversation(messageAttributes);
|
||||
|
||||
const state = window.reduxStore.getState();
|
||||
const selectedId = state?.conversations?.selectedConversationId;
|
||||
const inActiveConversation =
|
||||
conversation && selectedId && conversation.id === selectedId;
|
||||
|
||||
if (now - timeLastAccessed > expiryTime && !inActiveConversation) {
|
||||
this.__DEPRECATED$unregister(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finds a message in the cache by sender identifier
|
||||
public findBySender(
|
||||
senderIdentifier: string
|
||||
): Readonly<MessageAttributesType> | undefined {
|
||||
const id = this.state.messageIdsBySender.get(senderIdentifier);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.accessAttributes(id);
|
||||
}
|
||||
|
||||
public replaceAllObsoleteConversationIds({
|
||||
conversationId,
|
||||
obsoleteId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
obsoleteId: string;
|
||||
}): void {
|
||||
for (const [messageId, messageAttributes] of this.state.messages) {
|
||||
if (messageAttributes.conversationId !== obsoleteId) {
|
||||
continue;
|
||||
}
|
||||
this.setAttributes({
|
||||
messageId,
|
||||
messageAttributes: { conversationId },
|
||||
skipSaveToDatabase: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find the message's attributes whether in memory or in the database.
|
||||
// Refresh the attributes in the cache if they exist. Throw if we cannot find
|
||||
// a matching message.
|
||||
public async resolveAttributes(
|
||||
source: string,
|
||||
messageId: string
|
||||
): Promise<Readonly<MessageAttributesType>> {
|
||||
const inMemoryMessageAttributes = this.accessAttributes(messageId);
|
||||
|
||||
if (inMemoryMessageAttributes) {
|
||||
return inMemoryMessageAttributes;
|
||||
}
|
||||
|
||||
let messageAttributesFromDatabase: MessageAttributesType | undefined;
|
||||
try {
|
||||
messageAttributesFromDatabase = await window.Signal.Data.getMessageById(
|
||||
messageId
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
log.error(
|
||||
`MessageCache.resolveAttributes(${messageId}): db error ${Errors.toLogFormat(
|
||||
err
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
strictAssert(
|
||||
messageAttributesFromDatabase,
|
||||
`MessageCache.resolveAttributes/${source}: no message`
|
||||
);
|
||||
|
||||
return this.freezeAttributes(messageAttributesFromDatabase);
|
||||
}
|
||||
|
||||
// Updates a message's attributes and saves the message to cache and to the
|
||||
// database. Option to skip the save to the database.
|
||||
public setAttributes({
|
||||
messageId,
|
||||
messageAttributes: partialMessageAttributes,
|
||||
skipSaveToDatabase = false,
|
||||
}: {
|
||||
messageId: string;
|
||||
messageAttributes: Partial<MessageAttributesType>;
|
||||
skipSaveToDatabase: boolean;
|
||||
}): void {
|
||||
let messageAttributes = this.accessAttributes(messageId);
|
||||
|
||||
softAssert(messageAttributes, 'could not find message attributes');
|
||||
if (!messageAttributes) {
|
||||
// We expect message attributes to be defined in cache if one is trying to
|
||||
// set new attributes. In the case that the attributes are missing in cache
|
||||
// we'll add whatever we currently have to cache as a defensive measure so
|
||||
// that the code continues to work properly downstream. The softAssert above
|
||||
// that logs/debugger should be addressed upstream immediately by ensuring
|
||||
// that message is in cache.
|
||||
const partiallyCachedMessage = {
|
||||
id: messageId,
|
||||
...partialMessageAttributes,
|
||||
} as MessageAttributesType;
|
||||
|
||||
this.addMessageToCache(partiallyCachedMessage);
|
||||
messageAttributes = partiallyCachedMessage;
|
||||
}
|
||||
|
||||
this.state.messageIdsBySender.delete(
|
||||
getSenderIdentifier(messageAttributes)
|
||||
);
|
||||
|
||||
const nextMessageAttributes = {
|
||||
...messageAttributes,
|
||||
...partialMessageAttributes,
|
||||
};
|
||||
|
||||
const { id, sent_at: sentAt } = nextMessageAttributes;
|
||||
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
|
||||
|
||||
let nextIdsBySentAtSet: Set<string>;
|
||||
if (previousIdsBySentAt) {
|
||||
nextIdsBySentAtSet = new Set(previousIdsBySentAt);
|
||||
nextIdsBySentAtSet.add(id);
|
||||
} else {
|
||||
nextIdsBySentAtSet = new Set([id]);
|
||||
}
|
||||
|
||||
this.state.messages.set(id, nextMessageAttributes);
|
||||
this.state.lastAccessedAt.set(id, Date.now());
|
||||
this.state.messageIdsBySender.set(
|
||||
getSenderIdentifier(messageAttributes),
|
||||
id
|
||||
);
|
||||
|
||||
this.markModelStale(nextMessageAttributes);
|
||||
|
||||
if (window.reduxActions) {
|
||||
window.reduxActions.conversations.messageChanged(
|
||||
messageId,
|
||||
nextMessageAttributes.conversationId,
|
||||
nextMessageAttributes
|
||||
);
|
||||
}
|
||||
|
||||
if (skipSaveToDatabase) {
|
||||
return;
|
||||
}
|
||||
drop(
|
||||
window.Signal.Data.saveMessage(messageAttributes, {
|
||||
ourAci: window.textsecure.storage.user.getCheckedAci(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// When you already have the message attributes from the db and want to
|
||||
// ensure that they're added to the cache. The latest attributes from cache
|
||||
// are returned if they exist, if not the attributes passed in are returned.
|
||||
public toMessageAttributes(
|
||||
messageAttributes: MessageAttributesType
|
||||
): Readonly<MessageAttributesType> {
|
||||
this.addMessageToCache(messageAttributes);
|
||||
|
||||
const nextMessageAttributes = this.state.messages.get(messageAttributes.id);
|
||||
strictAssert(
|
||||
nextMessageAttributes,
|
||||
`MessageCache.toMessageAttributes: no message for id ${messageAttributes.id}`
|
||||
);
|
||||
|
||||
if (getEnvironment() === Environment.Development) {
|
||||
return Object.freeze(cloneDeep(nextMessageAttributes));
|
||||
}
|
||||
return nextMessageAttributes;
|
||||
}
|
||||
|
||||
static install(): MessageCache {
|
||||
const instance = new MessageCache();
|
||||
window.MessageCache = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
private addMessageToCache(messageAttributes: MessageAttributesType): void {
|
||||
if (!messageAttributes.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.messages.has(messageAttributes.id)) {
|
||||
this.state.lastAccessedAt.set(messageAttributes.id, Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, sent_at: sentAt } = messageAttributes;
|
||||
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
|
||||
|
||||
let nextIdsBySentAtSet: Set<string>;
|
||||
if (previousIdsBySentAt) {
|
||||
nextIdsBySentAtSet = new Set(previousIdsBySentAt);
|
||||
nextIdsBySentAtSet.add(id);
|
||||
} else {
|
||||
nextIdsBySentAtSet = new Set([id]);
|
||||
}
|
||||
|
||||
this.state.messages.set(messageAttributes.id, { ...messageAttributes });
|
||||
this.state.lastAccessedAt.set(messageAttributes.id, Date.now());
|
||||
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
|
||||
this.state.messageIdsBySender.set(
|
||||
getSenderIdentifier(messageAttributes),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
private freezeAttributes(
|
||||
messageAttributes: MessageAttributesType
|
||||
): Readonly<MessageAttributesType> {
|
||||
this.addMessageToCache(messageAttributes);
|
||||
|
||||
if (getEnvironment() === Environment.Development) {
|
||||
return Object.freeze(cloneDeep(messageAttributes));
|
||||
}
|
||||
return messageAttributes;
|
||||
}
|
||||
|
||||
private removeMessage(messageId: string): void {
|
||||
const messageAttributes = this.state.messages.get(messageId);
|
||||
if (!messageAttributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, sent_at: sentAt } = messageAttributes;
|
||||
const nextIdsBySentAtSet =
|
||||
new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set();
|
||||
|
||||
nextIdsBySentAtSet.delete(id);
|
||||
|
||||
if (nextIdsBySentAtSet.size) {
|
||||
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
|
||||
} else {
|
||||
this.state.messageIdsBySentAt.delete(sentAt);
|
||||
}
|
||||
|
||||
this.state.messages.delete(messageId);
|
||||
this.state.lastAccessedAt.delete(messageId);
|
||||
this.state.messageIdsBySender.delete(
|
||||
getSenderIdentifier(messageAttributes)
|
||||
);
|
||||
}
|
||||
|
||||
// Deprecated methods below
|
||||
|
||||
// Adds the message into the cache and eturns a Proxy that resembles
|
||||
// a MessageModel
|
||||
public __DEPRECATED$register(
|
||||
id: string,
|
||||
data: MessageModel | MessageAttributesType,
|
||||
location: string
|
||||
): MessageModel {
|
||||
if (!id || !data) {
|
||||
throw new Error(
|
||||
'MessageCache.__DEPRECATED$register: Got falsey id or message'
|
||||
);
|
||||
}
|
||||
|
||||
const existing = this.__DEPRECATED$getById(id);
|
||||
|
||||
if (existing) {
|
||||
this.addMessageToCache(existing.attributes);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const modelProxy = this.toModel(data);
|
||||
const messageAttributes = 'attributes' in data ? data.attributes : data;
|
||||
this.addMessageToCache(messageAttributes);
|
||||
modelProxy.registerLocations.add(location);
|
||||
|
||||
return modelProxy;
|
||||
}
|
||||
|
||||
// Deletes the message from our cache
|
||||
public __DEPRECATED$unregister(id: string): void {
|
||||
const model = this.modelCache.get(id);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeMessage(id);
|
||||
this.modelCache.delete(id);
|
||||
}
|
||||
|
||||
// Finds a message in the cache by Id
|
||||
public __DEPRECATED$getById(id: string): MessageModel | undefined {
|
||||
const data = this.state.messages.get(id);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.toModel(data);
|
||||
}
|
||||
|
||||
// Finds a message in the cache by sentAt/timestamp
|
||||
public __DEPRECATED$filterBySentAt(sentAt: number): Iterable<MessageModel> {
|
||||
const items = this.state.messageIdsBySentAt.get(sentAt) ?? [];
|
||||
const attrs = items.map(id => this.accessAttributes(id)).filter(isNotNil);
|
||||
return map(attrs, data => this.toModel(data));
|
||||
}
|
||||
|
||||
// Marks cached model as "should be stale" to discourage continued use.
|
||||
// The model's attributes are directly updated so that the model is in sync
|
||||
// with the in-memory attributes.
|
||||
private markModelStale(messageAttributes: MessageAttributesType): void {
|
||||
const { id } = messageAttributes;
|
||||
const model = this.modelCache.get(id);
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.attributes = { ...messageAttributes };
|
||||
|
||||
if (getEnvironment() === Environment.Development) {
|
||||
log.warn('MessageCache: stale model', {
|
||||
cid: model.cid,
|
||||
locations: Array.from(model.registerLocations).join('+'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a proxy object for MessageModel which logs usage in development
|
||||
// so that we're able to migrate off of models
|
||||
private toModel(
|
||||
messageAttributes: MessageAttributesType | MessageModel
|
||||
): MessageModel {
|
||||
const existingModel = this.modelCache.get(messageAttributes.id);
|
||||
|
||||
if (existingModel) {
|
||||
return existingModel;
|
||||
}
|
||||
|
||||
const model =
|
||||
'attributes' in messageAttributes
|
||||
? messageAttributes
|
||||
: new window.Whisper.Message(messageAttributes);
|
||||
|
||||
const proxy = getMessageModelLogger(model);
|
||||
|
||||
this.modelCache.set(messageAttributes.id, proxy);
|
||||
|
||||
return proxy;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue