[next] MongoDB Indexes (#2142)

* feat: added mongo indexing support

* fix: fixed typescript issue

* chore: better types

* fix: revert debug stuff

* fix: addressed ts error

* feat: added config option to disable auto-indexing

* chore: reordered imports

* refactor: cleaned up some filepaths
This commit is contained in:
Wyatt Johnson
2019-02-06 17:53:34 +00:00
committed by GitHub
parent 7e8ef2189d
commit 9b0e6ed53b
26 changed files with 397 additions and 126 deletions
@@ -151,7 +151,11 @@ export async function findOrCreateOIDCUser(
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
let user = await retrieveUserWithProfile(db, tenant.id, {
// NOTE: (wyattjoh) as the current requirements do not allow multiple OIDC integrations, we are only getting the profile based on the OIDC provider.
type: "oidc",
id: sub,
});
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
+7
View File
@@ -140,6 +140,13 @@ const config = convict({
env: "DISABLE_TENANT_CACHING",
arg: "disableTenantCaching",
},
disable_mongodb_autoindexing: {
doc: "Disables the creation of new MongoDB indexes",
format: Boolean,
default: false,
env: "DISABLE_MONGODB_AUTOINDEXING",
arg: "disableMongoDBAutoindexing",
},
});
export type Config = typeof config;
@@ -2,7 +2,7 @@ import { GraphQLScalarType } from "graphql";
import { Kind } from "graphql/language";
import { DateTime } from "luxon";
import { Cursor } from "talk-server/models/connection";
import { Cursor } from "talk-server/models/helpers/connection";
function parseIntegerCursor(value: string): number | null {
try {
@@ -19,7 +19,7 @@ import {
retrieveCommentUserConnection,
retrieveManyComments,
} from "talk-server/models/comment";
import { Connection } from "talk-server/models/connection";
import { Connection } from "talk-server/models/helpers/connection";
import { retrieveSharedModerationQueueQueuesCounts } from "talk-server/models/story/counts/shared";
import { SingletonResolver } from "./util";
@@ -7,7 +7,7 @@ import {
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { getLatestRevision } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/connection";
import { createConnection } from "talk-server/models/helpers/connection";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
@@ -4,7 +4,7 @@ import {
RejectCommentPayloadToModerationQueuesResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { CommentConnectionInput } from "talk-server/models/comment";
import { FilterQuery } from "talk-server/models/query";
import { FilterQuery } from "talk-server/models/helpers/query";
import {
CommentModerationCountsPerQueue,
Story,
@@ -1547,36 +1547,6 @@ type Story {
createdAt: Time!
}
"""
StoryEdge represents a unique Story in a StoryConnection.
"""
type StoryEdge {
"""
node is the Story for this edge.
"""
node: Story!
"""
"""
cursor: Cursor!
}
"""
StoriesConnection represents a subset of a Story list.
"""
type StoriesConnection {
"""
edges are a subset of StoryEdge's.
"""
edges: [StoryEdge!]!
"""
pageInfo is
"""
pageInfo: PageInfo!
}
################################################################################
## CommentsFilterInput
################################################################################
+13 -1
View File
@@ -15,6 +15,7 @@ import logger from "talk-server/logger";
import { createQueue, TaskQueue } from "talk-server/queue";
import { createJWTSigningConfig } from "talk-server/services/jwt";
import { createMongoDB } from "talk-server/services/mongodb";
import { ensureIndexes } from "talk-server/services/mongodb/indexes";
import { AugmentedRedis, createRedisClient } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
@@ -22,6 +23,10 @@ export interface ServerOptions {
config?: Config;
}
export interface ServerConnectOptions {
isWorker?: boolean;
}
/**
* Server provides an interface to create, start, and manage a Talk Server.
*/
@@ -74,7 +79,7 @@ class Server {
};
}
public async connect() {
public async connect({ isWorker }: ServerConnectOptions = {}) {
// Guard against double connecting.
if (this.connected) {
throw new Error("server has already connected");
@@ -84,6 +89,13 @@ class Server {
// Setup MongoDB.
this.mongo = await createMongoDB(config);
// If the instance being connected is not a worker process, then create the
// database indexes if it isn't disabled.
if (!isWorker && !this.config.get("disable_mongodb_autoindexing")) {
// Setup the database indexes.
await ensureIndexes(this.mongo);
}
// Setup Redis.
this.redis = createRedisClient(config);
+15 -2
View File
@@ -11,7 +11,10 @@ import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_FLAG_REPORTED_REASON,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { FilterQuery } from "talk-server/models/query";
import {
createIndexFactory,
FilterQuery,
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
@@ -103,6 +106,16 @@ export interface CommentAction extends TenantResource {
metadata?: Record<string, any>;
}
export async function createCommentActionIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
// { actionType, commentID }
await createIndex({ tenantID: 1, actionType: 1, commentID: 1, userID: 1 });
}
const ActionSchema = [
// Flags
{
@@ -245,9 +258,9 @@ export async function retrieveUserAction(
) {
return collection(mongo).findOne({
tenantID,
actionType,
commentID,
userID,
actionType,
});
}
@@ -8,8 +8,11 @@ import {
ConnectionInput,
getPageInfo,
nodesToEdges,
} from "talk-server/models/connection";
import Query from "talk-server/models/query";
} from "talk-server/models/helpers/connection";
import Query, {
createConnectionOrderVariants,
createIndexFactory,
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
@@ -18,6 +21,22 @@ function collection(db: Db) {
);
}
export async function createCommentModerationActionIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
const createVariants = createConnectionOrderVariants<
Readonly<CommentModerationAction>
>([{ createdAt: -1 }]);
// { moderatorID, ...connectionParams }
await createVariants(createIndex, {
moderatorID: 1,
});
}
/**
* CommentModerationAction stores information around a moderation action that
* was created for a given Comment Revision.
+54 -2
View File
@@ -19,8 +19,11 @@ import {
nodesToEdges,
NodeToCursorTransformer,
OrderedConnectionInput,
} from "talk-server/models/connection";
import Query from "talk-server/models/query";
} from "talk-server/models/helpers/connection";
import Query, {
createConnectionOrderVariants,
createIndexFactory,
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(mongo: Db) {
@@ -137,6 +140,54 @@ export interface Comment extends TenantResource {
metadata?: Record<string, any>;
}
export async function createCommentIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
const variants = createConnectionOrderVariants<Readonly<Comment>>([
{ createdAt: -1 },
{ createdAt: 1 },
{ replyCount: -1, createdAt: -1 },
{ "actionCounts.REACTION": -1, createdAt: -1 },
]);
// Story based Comment Connection pagination.
// { storyID, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
storyID: 1,
status: 1,
});
// Story based Comment Connection pagination that are flagged.
// { storyID, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
storyID: 1,
status: 1,
"actionCounts.FLAG": 1,
});
// Story + Reply based Comment Connection pagination.
// { storyID, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
storyID: 1,
parentID: 1,
status: 1,
});
// Author based Comment Connection pagination.
// { authorID, ...connectionParams }
await variants(createIndex, {
tenantID: 1,
authorID: 1,
status: 1,
});
}
export type CreateCommentInput = Omit<
Comment,
| "id"
@@ -670,6 +721,7 @@ function applyInputToQuery(
input: CommentConnectionInput,
query: Query<Comment>
) {
// NOTE: (wyattjoh) if we ever extend these, ensure that the new order variant is added as an index into the `createCommentConnectionOrderVariants` function.
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
query.orderBy({ createdAt: -1 });
@@ -1,5 +1,6 @@
import { merge } from "lodash";
import { FilterQuery } from "./query";
import { FilterQuery } from "talk-server/models/helpers/query";
export type Cursor = Date | number | string | null;
@@ -1,7 +1,13 @@
import { merge } from "lodash";
import { Collection, Cursor } from "mongodb";
import { FilterQuery as MongoFilterQuery } from "mongodb";
import { Writeable } from "../../common/types";
import {
Collection,
Cursor,
FilterQuery as MongoFilterQuery,
IndexOptions,
} from "mongodb";
import { Writeable } from "talk-common/types";
import logger from "talk-server/logger";
/**
* FilterQuery<T> ensures that given the type T, that the FilterQuery will be a
@@ -90,3 +96,69 @@ export default class Query<T> {
return cursor;
}
}
type IndexType = 1 | -1;
export type IndexSpecification<T> = {
[P in keyof Writeable<Partial<T>>]: IndexType
} &
Record<string, IndexType>;
type IndexCreationFunction<T> = (
indexSpec: IndexSpecification<T>,
indexOptions?: IndexOptions
) => Promise<string>;
export function createIndexFactory<T>(
collection: Collection<T>
): IndexCreationFunction<T> {
const log = logger.child({
collectionName: collection.collectionName,
});
return async (
indexSpec: IndexSpecification<T>,
indexOptions: IndexOptions = {}
) => {
try {
// Try to create the index.
const indexName = await collection.createIndex(indexSpec, indexOptions);
log.debug({ indexName, indexSpec, indexOptions }, "index was created");
// Match the interface from the `createIndex` function by returning the
// index name.
return indexName;
} catch (err) {
log.error({ err, indexSpec, indexOptions }, "could not create index");
// Rethrow the error here.
throw err;
}
};
}
export function createConnectionOrderVariants<T>(
variants: Array<IndexSpecification<T>>
) {
return async (
createIndex: IndexCreationFunction<T>,
indexSpec: IndexSpecification<T>
) => {
/**
* createIndexVariant will create a variant on the specified `indexSpec` that
* will include the new variation.
*
* @param variantSpec the spec that makes this variant different
*/
const createIndexVariant = (variantSpec: IndexSpecification<T>) =>
createIndex(merge({}, indexSpec, variantSpec));
// Create a raw index without the variants applied.
await createIndex(indexSpec);
// Create all the variants.
for (const variant of variants) {
await createIndexVariant(variant);
}
};
}
+14 -6
View File
@@ -1,15 +1,23 @@
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { CommentModerationQueueCounts, CommentStatusCounts } from ".";
import {
CommentModerationCountsPerQueue,
CommentModerationQueueCounts,
CommentStatusCounts,
} from ".";
export function createEmptyCommentModerationCountsPerQueue(): CommentModerationCountsPerQueue {
return {
unmoderated: 0,
reported: 0,
pending: 0,
};
}
export function createEmptyCommentModerationQueueCounts(): CommentModerationQueueCounts {
return {
total: 0,
queues: {
unmoderated: 0,
reported: 0,
pending: 0,
},
queues: createEmptyCommentModerationCountsPerQueue(),
};
}
+10 -2
View File
@@ -1,17 +1,18 @@
export * from "./empty";
export * from "./shared";
import { identity, isEmpty, pickBy } from "lodash";
import { Db } from "mongodb";
import { identity, isEmpty, pickBy } from "lodash";
import { DeepPartial } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import { createIndexFactory } from "talk-server/models/helpers/query";
import { retrieveStory, Story } from "talk-server/models/story";
import { AugmentedRedis } from "talk-server/services/redis";
import { retrieveStory, Story } from "..";
import { createEmptyCommentStatusCounts } from "./empty";
import { updateSharedCommentCounts } from "./shared";
@@ -23,6 +24,13 @@ function collection<T = Story>(db: Db) {
return db.collection<Readonly<T>>("stories");
}
export async function createStoryCountIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// { createdAt }
await createIndex({ tenantID: 1, createdAt: 1 });
}
// TODO: (wyattjoh) write a test to verify that this set of counts is always in sync with GQLCOMMENT_STATUS.
/**
+47 -27
View File
@@ -4,15 +4,16 @@ import ms from "ms";
import logger from "talk-server/logger";
import { EncodedCommentActionCounts } from "talk-server/models/action/comment";
import { AugmentedPipeline, AugmentedRedis } from "talk-server/services/redis";
import { Story } from "talk-server/models/story";
import {
CommentModerationCountsPerQueue,
CommentStatusCounts,
StoryCounts,
} from ".";
import { Story } from "..";
} from "talk-server/models/story/counts";
import { AugmentedPipeline, AugmentedRedis } from "talk-server/services/redis";
import {
createEmptyCommentModerationCountsPerQueue,
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
} from "./empty";
@@ -348,21 +349,28 @@ export async function recalculateSharedCommentCounts(
]);
}
/**
* stringObjectToNumber will convert an object that has string keys and string
* values into string keys and number values.
*/
function stringObjectToNumber<T>(
input: Record<string, string>,
defaultValue: number = 0
): T {
return Object.entries(input).reduce(
(acc, [key, value]) => ({
...acc,
[key]: parseInt(value, 10) || defaultValue,
}),
{}
) as T;
function fillAndConvertStringToNumber<
T extends { [P in keyof T]?: string } &
{ [P in Exclude<keyof T, keyof U>]?: never },
U extends { [P in keyof U]: number }
>(input: T, initial: U): U {
const result: U = Object.assign({}, initial);
for (const key in input) {
if (!input.hasOwnProperty(key)) {
continue;
}
// Pull out the value.
const value: string | undefined = input[key] as any;
if (!value) {
continue;
}
// I know, not ideal, but...
(result as any)[key] = parseInt(value, 10) || 0;
}
return result;
}
/**
@@ -378,7 +386,7 @@ export async function retrieveSharedActionCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
): Promise<EncodedCommentActionCounts> {
const key = commentCountsActionKey(tenantID);
const freshKey = freshenKey(key);
@@ -395,7 +403,10 @@ export async function retrieveSharedActionCommentCounts(
return recalculateSharedActionCommentCounts(mongo, redis, tenantID);
}
return stringObjectToNumber<EncodedCommentActionCounts>(actions);
return fillAndConvertStringToNumber(
actions,
{} as EncodedCommentActionCounts
);
}
/**
@@ -411,13 +422,13 @@ export async function retrieveSharedStatusCommentCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
): Promise<CommentStatusCounts> {
const key = commentCountsStatusKey(tenantID);
const freshKey = freshenKey(key);
// Get the values, and the freshness key.
const [[, statuses], [, fresh]]: [
[Error | undefined, Record<string, string> | null],
[Error | undefined, Record<keyof CommentStatusCounts, string> | null],
[Error | undefined, string | null]
] = await redis
.pipeline()
@@ -428,7 +439,10 @@ export async function retrieveSharedStatusCommentCounts(
return recalculateSharedStatusCommentCounts(mongo, redis, tenantID);
}
return stringObjectToNumber<CommentStatusCounts>(statuses);
return fillAndConvertStringToNumber(
statuses,
createEmptyCommentStatusCounts()
);
}
/**
@@ -473,13 +487,16 @@ export async function retrieveSharedModerationQueueQueuesCounts(
mongo: Db,
redis: AugmentedRedis,
tenantID: string
) {
): Promise<CommentModerationCountsPerQueue> {
const key = commentCountsModerationQueueQueuesKey(tenantID);
const freshKey = freshenKey(key);
// Get the values, and the freshness key.
const [[, queues], [, fresh]]: [
[Error | undefined, Record<string, string> | null],
[
Error | undefined,
Record<keyof CommentModerationCountsPerQueue, string> | null
],
[Error | undefined, string | null]
] = await redis
.pipeline()
@@ -493,7 +510,10 @@ export async function retrieveSharedModerationQueueQueuesCounts(
logger.debug({ tenantID }, "comment moderation counts were cached");
return stringObjectToNumber<CommentModerationCountsPerQueue>(queues);
return fillAndConvertStringToNumber(
queues,
createEmptyCommentModerationCountsPerQueue()
);
}
export async function updateSharedCommentCounts(
+12
View File
@@ -4,8 +4,10 @@ import uuid from "uuid";
import { Omit } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import { createIndexFactory } from "talk-server/models/helpers/query";
import { ModerationSettings } from "talk-server/models/settings";
import { TenantResource } from "talk-server/models/tenant";
import {
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
@@ -60,6 +62,16 @@ export interface Story extends TenantResource {
createdAt: Date;
}
export async function createStoryIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
// UNIQUE { url }
await createIndex({ tenantID: 1, url: 1 }, { unique: true });
}
export interface UpsertStoryInput {
id?: string;
url: string;
+11
View File
@@ -6,6 +6,7 @@ import uuid from "uuid";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize, DotizeOptions } from "talk-common/utils/dotize";
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
import { createIndexFactory } from "talk-server/models/helpers/query";
import { Settings } from "talk-server/models/settings";
function collection(db: Db) {
@@ -42,6 +43,16 @@ export interface Tenant extends Settings {
organizationContactEmail: string;
}
export async function createTenantIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ id: 1 }, { unique: true });
// UNIQUE { domain }
await createIndex({ domain: 1 }, { unique: true });
}
/**
* CreateTenantInput is the set of properties that can be set when a given
* Tenant is created. The remainder of the properties are set from defaults and
+36 -5
View File
@@ -4,11 +4,14 @@ import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { FilterQuery } from "talk-server/models/query";
import {
createIndexFactory,
FilterQuery,
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<User>>("users");
function collection(mongo: Db) {
return mongo.collection<Readonly<User>>("users");
}
export interface LocalProfile {
@@ -66,6 +69,34 @@ export interface User extends TenantResource {
createdAt: Date;
}
export async function createUserIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ tenantID: 1, id: 1 }, { unique: true });
// UNIQUE - PARTIAL { lowercaseUsername }
await createIndex(
{ tenantID: 1, lowercaseUsername: 1 },
{
unique: true,
partialFilterExpression: { lowercaseUsername: { $exists: true } },
}
);
// UNIQUE - PARTIAL { email }
await createIndex(
{ tenantID: 1, email: 1 },
{ unique: true, partialFilterExpression: { email: { $exists: true } } }
);
// UNIQUE { profiles.type, profiles.id }
await createIndex(
{ tenantID: 1, "profiles.type": 1, "profiles.id": 1 },
{ unique: true }
);
}
function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
@@ -171,7 +202,7 @@ const createUpsertUserFilter = (user: Readonly<User>) => {
};
export async function retrieveUser(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenantID });
return collection(db).findOne({ tenantID, id });
}
export async function retrieveManyUsers(
@@ -194,7 +225,7 @@ export async function retrieveManyUsers(
export async function retrieveUserWithProfile(
db: Db,
tenantID: string,
profile: Partial<Profile>
profile: Partial<Pick<Profile, "id" | "type">>
) {
return collection(db).findOne({
tenantID,
@@ -0,0 +1,45 @@
import { Db } from "mongodb";
import logger from "talk-server/logger";
import { createCommentActionIndexes } from "talk-server/models/action/comment";
import { createCommentModerationActionIndexes } from "talk-server/models/action/moderation/comment";
import { createCommentIndexes } from "talk-server/models/comment";
import {
createStoryCountIndexes,
createStoryIndexes,
} from "talk-server/models/story";
import { createTenantIndexes } from "talk-server/models/tenant";
import { createUserIndexes } from "talk-server/models/user";
type IndexCreationFunction = (mongo: Db) => Promise<void>;
const indexes: Array<[string, IndexCreationFunction]> = [
["users", createUserIndexes],
["tenants", createTenantIndexes],
["comments", createCommentIndexes],
["stories", createStoryIndexes],
["stories", createStoryCountIndexes],
["commentActions", createCommentActionIndexes],
["commentModerationActions", createCommentModerationActionIndexes],
];
/**
* ensureIndexes will ensure that all indexes have been created.
*
* @param mongo a MongoDB Database Connection
*/
export async function ensureIndexes(mongo: Db) {
logger.debug(
{ indexGroupCount: indexes.length },
"now ensuring indexes are created"
);
// For each of the index functions, call it.
for (const [indexGroup, indexFunction] of indexes) {
logger.debug({ indexGroup }, "ensuring indexes are created");
await indexFunction(mongo);
logger.debug({ indexGroup }, "indexes have been created");
}
logger.debug("all indexes have been created");
}
+4
View File
@@ -50,6 +50,10 @@ export async function install(
);
}
// TODO: (wyattjoh) perform any pending migrations.
// TODO: (wyattjoh) setup database indexes.
// Create the Tenant.
const tenant = await createTenant(mongo, input);
+5 -4
View File
@@ -20,7 +20,7 @@ async function worker(server: Server) {
logger.debug("started server worker");
// Connect the server to databases.
await server.connect();
await server.connect({ isWorker: true });
// Start the server.
await server.start(app);
@@ -31,6 +31,9 @@ async function worker(server: Server) {
// master will start the master process.
async function master(server: Server) {
const workerCount = server.config.get("concurrency");
logger.debug({ workerCount }, "spawning workers to handle traffic");
try {
// Connect the server to databases.
await server.connect();
@@ -60,7 +63,7 @@ async function bootstrap() {
);
// Connect the server to databases.
await server.connect();
await server.connect({});
// Process jobs.
await server.process();
@@ -68,8 +71,6 @@ async function bootstrap() {
// Start the server.
await server.start(app);
} else {
logger.debug({ workerCount }, "spawning workers to handle traffic");
// Launch the server start within throng.
throng({
workers: workerCount,