Skip to content

Commit f4e22fb

Browse files
committed
feat(replication): adds read replica functionality
1 parent d7fb0f2 commit f4e22fb

File tree

14 files changed

+147
-161
lines changed

14 files changed

+147
-161
lines changed

examples/12-replica-db-connection/.env

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ WARTHOG_DB_HOST=localhost
1010
WARTHOG_DB_PORT=5432
1111
WARTHOG_DB_REPLICA_HOST=localhost
1212
WARTHOG_DB_REPLICA_PORT=5432
13-
WARTHOG_DB_CONNECT_REPLICA=true
13+
WARTHOG_DB_REPLICA_DATABASE=warthog-example-12
14+
WARTHOG_DB_REPLICA_USERNAME=postgres
15+
WARTHOG_DB_REPLICA_PASSWORD=

examples/12-replica-db-connection/generated/binding.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as schema from './schema.graphql'
77

88
export interface Query {
99
users: <T = Array<User>>(args: { offset?: Int | null, limit?: Int | null, where?: UserWhereInput | null, orderBy?: UserOrderByInput | null }, info?: GraphQLResolveInfo | string, options?: Options) => Promise<T> ,
10+
usersFromMaster: <T = Array<User>>(args?: {}, info?: GraphQLResolveInfo | string, options?: Options) => Promise<T> ,
1011
user: <T = User>(args: { where: UserWhereUniqueInput }, info?: GraphQLResolveInfo | string, options?: Options) => Promise<T>
1112
}
1213

examples/12-replica-db-connection/generated/schema.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ type PageInfo {
7979

8080
type Query {
8181
users(offset: Int, limit: Int = 50, where: UserWhereInput, orderBy: UserOrderByInput): [User!]!
82+
usersFromMaster: [User!]!
8283
user(where: UserWhereUniqueInput!): User!
8384
}
8485

examples/12-replica-db-connection/src/server.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'reflect-metadata';
2+
import { AdvancedConsoleLogger, Logger, QueryRunner } from 'typeorm';
23

34
import { BaseContext, Server } from '../../../src';
45

@@ -10,6 +11,16 @@ interface Context extends BaseContext {
1011
};
1112
}
1213

14+
export class CustomLogger extends AdvancedConsoleLogger implements Logger {
15+
logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
16+
if (!queryRunner) {
17+
return console.log(query);
18+
}
19+
20+
console.log(`[${(queryRunner as any).mode}] ${query}`);
21+
}
22+
}
23+
1324
export function getServer(AppOptions = {}, dbOptions = {}) {
1425
return new Server<Context>(
1526
{
@@ -23,9 +34,8 @@ export function getServer(AppOptions = {}, dbOptions = {}) {
2334
}
2435
};
2536
},
26-
connectDBReplica: true,
2737
...AppOptions
2838
},
29-
dbOptions
39+
{ ...dbOptions, logger: new CustomLogger() }
3040
);
3141
}

examples/12-replica-db-connection/src/user.resolver.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export class UserResolver {
2222
return this.userService.find<UserWhereInput>(where, orderBy, limit, offset);
2323
}
2424

25+
// This query will use the "master" server, even though it would typically use "slave" by default for doing a find
26+
@Query(() => [User])
27+
async usersFromMaster(): Promise<User[]> {
28+
return this.userService.findFromMaster();
29+
}
30+
2531
@Query(() => User)
2632
async user(@Arg('where') where: UserWhereUniqueInput): Promise<User> {
2733
return this.userService.findOne<UserWhereUniqueInput>(where);
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Service } from 'typedi';
2-
import { DeepPartial, Repository } from 'typeorm';
2+
import { Repository } from 'typeorm';
33
import { InjectRepository } from 'typeorm-typedi-extensions';
44

55
import { BaseService } from '../../../src';
@@ -12,11 +12,10 @@ export class UserService extends BaseService<User> {
1212
super(User, repository);
1313
}
1414

15-
async create(data: DeepPartial<User>, userId: string): Promise<User> {
16-
const newUser = await super.create(data, userId);
17-
18-
// Perform some side effects
19-
20-
return newUser;
15+
// This query will use the "master" server, even though it would typically use "slave" by default for doing a find
16+
async findFromMaster(): Promise<User[]> {
17+
return super.find(undefined, undefined, undefined, undefined, undefined, {
18+
replicationMode: 'master'
19+
});
2120
}
2221
}

src/core/BaseService.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
DeepPartial,
66
EntityManager,
77
getRepository,
8+
QueryBuilder,
9+
ReplicationMode,
810
Repository,
911
SelectQueryBuilder
1012
} from 'typeorm';
@@ -34,6 +36,10 @@ export interface BaseOptions {
3436
manager?: EntityManager; // Allows consumers to pass in a TransactionManager
3537
}
3638

39+
export interface AdvancedFindOptions {
40+
replicationMode?: ReplicationMode;
41+
}
42+
3743
interface WhereFilterAttributes {
3844
[key: string]: string | number | null;
3945
}
@@ -114,12 +120,13 @@ export class BaseService<E extends BaseModel> {
114120
orderBy?: string,
115121
limit?: number,
116122
offset?: number,
117-
fields?: string[]
123+
fields?: string[],
124+
options: AdvancedFindOptions = {}
118125
): Promise<E[]> {
119126
// TODO: FEATURE - make the default limit configurable
120127
limit = limit ?? 20;
121128
debugStatement('find:buildQuery');
122-
const qb = this.buildFindQuery<W>(where, orderBy, { limit, offset }, fields);
129+
const qb = this.buildFindQuery<W>(where, orderBy, { limit, offset }, fields, options);
123130
try {
124131
debugStatement('find:gettingMany');
125132
const records = await qb.getMany();
@@ -129,6 +136,8 @@ export class BaseService<E extends BaseModel> {
129136
debugStatement('find:error');
130137
logger.error('failed on getMany', e);
131138
throw e;
139+
} finally {
140+
this.cleanUpQueryBuilder(qb);
132141
}
133142
}
134143

@@ -137,7 +146,8 @@ export class BaseService<E extends BaseModel> {
137146
whereUserInput: any = {}, // V3: WhereExpression = {},
138147
orderBy?: string | string[],
139148
_pageOptions: RelayPageOptionsInput = {},
140-
fields?: ConnectionInputFields
149+
fields?: ConnectionInputFields,
150+
options: AdvancedFindOptions = {}
141151
): Promise<ConnectionResult<E>> {
142152
// TODO: if the orderby items aren't included in `fields`, should we automatically include?
143153

@@ -176,7 +186,8 @@ export class BaseService<E extends BaseModel> {
176186
whereCombined,
177187
this.relayService.effectiveOrderStrings(sorts, relayPageOptions),
178188
{ limit: limit + 1 }, // We ask for 1 too many so that we know if there is an additional page
179-
requestedFields.selectFields
189+
requestedFields.selectFields,
190+
options
180191
);
181192

182193
let rawData;
@@ -189,6 +200,8 @@ export class BaseService<E extends BaseModel> {
189200
rawData = await qb.getMany();
190201
}
191202

203+
this.cleanUpQueryBuilder(qb);
204+
192205
// If we got the n+1 that we requested, pluck the last item off
193206
const returnData = rawData.length > limit ? rawData.slice(0, limit) : rawData;
194207

@@ -209,13 +222,19 @@ export class BaseService<E extends BaseModel> {
209222
where: WhereExpression = {},
210223
orderBy?: string | string[],
211224
pageOptions?: LimitOffset,
212-
fields?: string[]
225+
fields?: string[],
226+
options: AdvancedFindOptions = {}
213227
): SelectQueryBuilder<E> {
214228
try {
215229
const DEFAULT_LIMIT = 50;
216-
let qb = this.manager.connection
217-
.createQueryBuilder<E>(this.entityClass, this.klass)
218-
.setQueryRunner(this.manager.connection.createQueryRunner('slave'));
230+
let qb = this.manager.connection.createQueryBuilder<E>(this.entityClass, this.klass);
231+
if (options.replicationMode) {
232+
const queryRunner = this.manager.connection.createQueryRunner(options.replicationMode);
233+
qb.setQueryRunner(queryRunner);
234+
(qb as any).warthogQueryRunnerOverride = queryRunner;
235+
}
236+
237+
//
219238
if (!pageOptions) {
220239
pageOptions = {
221240
limit: DEFAULT_LIMIT
@@ -476,6 +495,15 @@ export class BaseService<E extends BaseModel> {
476495
return { id: found.id };
477496
}
478497

498+
// This is really ugly. Shouldn't be attaching to the querybuilder, but need to keep track of whether this
499+
// instance of the queryBuilder was created with a custom query runner so that it can be cleaned up
500+
cleanUpQueryBuilder(qb: QueryBuilder<E>) {
501+
// console.log(qb);
502+
if ((qb as any).warthogQueryRunnerOverride) {
503+
(qb as any).warthogQueryRunnerOverride.release();
504+
}
505+
}
506+
479507
attrsToDBColumns = (attrs: string[]): string[] => {
480508
return attrs.map(this.attrToDBColumn);
481509
};

src/core/server.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,14 @@ describe('Server', () => {
5050
const customExpressApp: express.Application = express();
5151
const appListenSpy = jest.spyOn(customExpressApp, 'listen');
5252
server = buildServer(
53-
{ expressApp: customExpressApp, connectDBReplica: true },
54-
{ WARTHOG_DB_CONNECT_REPLICA: 'true' }
53+
{ expressApp: customExpressApp },
54+
{
55+
WARTHOG_DB_REPLICA_HOST: 'localhost',
56+
WARTHOG_DB_REPLICA_DATABASE: 'warthog-test',
57+
WARTHOG_DB_REPLICA_PORT: '5432',
58+
WARTHOG_DB_REPLICA_USERNAME: 'postgres',
59+
WARTHOG_DB_REPLICA_PASSWORD: ''
60+
}
5561
);
5662
await server.start();
5763
const binding = await server.getBinding();

src/core/server.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Connection, ConnectionOptions, useContainer as TypeORMUseContainer } fr
1717
import { logger, Logger } from '../core/logger';
1818
import { getRemoteBinding } from '../gql';
1919
import { DataLoaderMiddleware, healthCheckMiddleware } from '../middleware';
20-
import { createDBConnection, createReplicatedDBConnection } from '../torm';
20+
import { createDBConnection, WarthogDBConnectionOptions } from '../torm';
2121

2222
import { CodeGenerator } from './code-generator';
2323
import { Config } from './config';
@@ -47,7 +47,6 @@ export interface ServerOptions<T> {
4747
bodyParserConfig?: OptionsJson;
4848
onBeforeGraphQLMiddleware?: (app: express.Application) => void;
4949
onAfterGraphQLMiddleware?: (app: express.Application) => void;
50-
connectDBReplica?: boolean;
5150
}
5251

5352
export class Server<C extends BaseContext> {
@@ -66,8 +65,7 @@ export class Server<C extends BaseContext> {
6665

6766
constructor(
6867
private appOptions: ServerOptions<C>,
69-
private dbOptions: Partial<ConnectionOptions> = {},
70-
private dbReplicaOptions: Partial<ConnectionOptions> = {}
68+
private dbOptions: Partial<WarthogDBConnectionOptions> = {}
7169
) {
7270
if (typeof this.appOptions.host !== 'undefined') {
7371
process.env.WARTHOG_APP_HOST = this.appOptions.host;
@@ -93,11 +91,6 @@ export class Server<C extends BaseContext> {
9391
? 'true'
9492
: 'false';
9593
}
96-
if (typeof this.appOptions.connectDBReplica !== 'undefined') {
97-
process.env.WARTHOG_DB_CONNECT_REPLICA = this.appOptions.connectDBReplica ? 'true' : 'false';
98-
} else {
99-
process.env.WARTHOG_DB_CONNECT_REPLICA = 'false';
100-
}
10194

10295
// Ensure that Warthog, TypeORM and TypeGraphQL are all using the same typedi container
10396
this.container = this.appOptions.container || Container;
@@ -132,13 +125,8 @@ export class Server<C extends BaseContext> {
132125
async establishDBConnection(): Promise<Connection> {
133126
if (!this.connection) {
134127
debug('establishDBConnection:start');
135-
if (this.config.get('WARTHOG_DB_CONNECT_REPLICA')) {
136-
this.connection = await createReplicatedDBConnection(this.dbOptions);
137-
this.allConnections = [this.connection];
138-
} else {
139-
this.connection = await createDBConnection(this.dbOptions);
140-
this.allConnections = [this.connection];
141-
}
128+
this.connection = await createDBConnection(this.dbOptions);
129+
this.allConnections = [this.connection];
142130
debug('establishDBConnection:end');
143131
}
144132

0 commit comments

Comments
 (0)