The `rawConnection` footgun
See original GitHub issueThis is a description of a couple of issues I noticed while using the TransactionalConnection.rawConnection
for making queries. I will paste some code, which reproduces the error, but which I haven’t tried isolated from my own project. When I have time I can try to replicate it in a standalone Vendure installation.
- Vendure version 1.4.7
- Database: Postgres 13
Issue 1: Using rawConnection
inside a field resolver can deplete* your connection pool (my guess*)
This is a nasty bug since your server will continue running but it won’t be able to service requests. In our case it manifested in a way that the site suddenly started hanging. Google Cloud Run did not kill the container and it was accepting but not servicing requests.
Here’s the code that caused it - when running a GQL query selecting the “categories” property of Product around 10 times and you’ll see that the entire server is blocked. My guess is because the connection pool of the pg driver is depleted.
Replacing await this.conn.rawConnection.createQueryRunner()
with await this.conn.getRepository(ctx, Category)
fixes the issue.
// A product entity resolver to return categories that the product belongs to
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Ctx, RequestContext, Product, TransactionalConnection } from '@vendure/core';
import { CategoryService } from '../service';
@Resolver('Product')
export class ProductEntityResolver {
constructor(protected service: CategoryService, private conn: TransactionalConnection) {}
@ResolveField()
async categories(@Ctx() ctx: RequestContext, @Parent() product: Product) {
const q = `
SELECT cfv1."categoryId" AS id
FROM category_facet_value AS cfv1
LEFT JOIN product_facet_values_facet_value AS pfv1
ON pfv1."facetValueId" = cfv1."facetValueId"
AND pfv1."productId" = $1
WHERE "categoryId" IN (
SELECT DISTINCT cfv2."categoryId"
FROM category_facet_value AS cfv2
INNER JOIN product_facet_values_facet_value AS pfv2 ON pfv2."facetValueId" = cfv2."facetValueId"
WHERE pfv2."productId" = $1 AND cc."channelId" = $2
)
GROUP BY "categoryId"
HAVING COUNT(cfv1."facetValueId") = COUNT(pfv1."facetValueId");`;
const results: { id: string }[] = await this.conn.rawConnection
.createQueryRunner()
.query(q, [product.id, ctx.channelId]);
return this.service.getCategoriesBySlugOrId(ctx, { ids: results.map(id => id) })
}
}
To make this work locally, you would need to define the following entity, and schema extensions for it, as well as the schema extensions for Product. But this can probably be replicated with a much simpler extension to product, maybe without including a new entity.
/**
* A category is defined over a set of facets.
* All products that have *ALL* the facets of a certain category belong to the category.
* All products in a category also belong to its ancestors
*/
@Entity('category')
export class Category extends VendureEntity implements ChannelAware {
constructor(input?: DeepPartial<Category>) {
super(input);
}
/**
* There is a single "root" category that contains all others
*/
@Column('boolean', { default: false })
isRoot: boolean;
/**
* The child categories
*/
@TreeChildren({ cascade: true })
children: Category[];
/**
* The parent category
*/
@TreeParent({ onDelete: 'CASCADE' })
parent: Category | null;
/**
* We sometimes need the parentId
*/
@RelationId((category: Category) => category.parent)
parentId: string;
/**
* Facet values that determine which products belong here.
*/
@OneToMany(() => CategoryFacetValue, catFacetValue => catFacetValue.category, {
cascade: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
catFacetValues: CategoryFacetValue[];
/**
* Categories belong to one or more channels
*/
@ManyToMany(() => Channel, {
cascade: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
orphanedRowAction: 'delete',
})
@JoinTable()
channels: Channel[];
/**
* Length of the children array
*/
get childrenCount() {
return this.children?.length || 0;
}
}
/**
* A link table between categories and facet values
*/
@Entity('category_facet_value')
export class CategoryFacetValue {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
facetValueId: string;
@Column('uuid')
categoryId: string;
@Column('varchar', { nullable: true })
mode: string | null;
@ManyToOne(() => FacetValue, { onDelete: 'CASCADE' })
facetValue: FacetValue;
@ManyToOne(() => Category, { onDelete: 'CASCADE' })
category: Category;
}
Issue 2: Using getRepository(ctx, Entity)
is orders of magnitude slower than using rawConnection
Since raw connection is to be avoided, Vendure does not offer a good way to run raw queries. The only way you can obtain a queryBuilder
bound to the current context is if you do getRepository
first. However, thus turns out to be almost three thousand times slower for some reason. I should note that this is run on a table with some 5000 facet values and 7000 products and the ids
is an array of 1168 ids - so not the most efficient query, but still, the difference is 8.3s vs 30ms.
Here’s the code.
console.time('Query')
await this.connection
.getRepository(ctx, FacetValue)
.createQueryBuilder()
.select('f.id', 'id')
.addSelect('COUNT(DISTINCT p.productId)', 'productCount')
.from('facet_value', 'f')
.leftJoin('product_facet_values_facet_value', 'p', 'p.facetValueId = f.id')
.where('f.id IN (:...ids)', { ids })
.groupBy('f.id')
.getRawMany();
console.timeEnd('Query') // 8.723s
console.time('RawConn')
await this.connection
.rawConnection
.createQueryBuilder()
.select('f.id', 'id')
.addSelect('COUNT(DISTINCT p.productId)', 'productCount')
.from('facet_value', 'f')
.leftJoin('product_facet_values_facet_value', 'p', 'p.facetValueId = f.id')
.where('f.id IN (:...ids)', { ids })
.groupBy('f.id')
.getRawMany();
console.timeEnd('RawConn') // 28.33ms
Issue Analytics
- State:
- Created 2 years ago
- Comments:5 (4 by maintainers)
Top GitHub Comments
@pleerock thanks for taking the time to advise on this! Yes, I ended up implementing concurrent queries rather than big joins for certain Vendure internals and did see very good perf gains! And when we upgrade to 0.3.x I’ll be able to use the new load strategy, so thanks for that!
since issue https://github.com/typeorm/typeorm/issues/2381 was pointed here, I just wanted to point that using two queries instead of using
joins
can solve the issue in most cases.