Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/recently-registered-subdomains-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ensnode/ensdb-sdk": patch
"ensindexer": patch
---

Index-accelerate `REGISTRATION_TIMESTAMP` / `REGISTRATION_EXPIRY`-ordered domain queries (e.g. `Domain.subdomains(order: { by: REGISTRATION_TIMESTAMP, dir: DESC })`). Previously these joined `domains → latest_registration_indexes → registrations` and sorted the full registry partition — ~55s for `.eth`'s subdomains. The latest registration's start/expiry is now mirrored onto the Domain row (`__latestRegistrationStart` / `__latestRegistrationExpiry`) with composite indexes `(registry_id, <col>, id)`, turning the query into an index-ordered scan. The sort columns are NOT NULL — an absent value (no registration, or a never-expiring registration) is materialized as a `+∞` sentinel — so a single plain composite per column serves both directions with a plain keyset tuple, and the sentinel sorts last for ASC and first for DESC.
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,24 @@ function getOrderColumn(orderBy: typeof DomainsOrderBy.$inferType): SQL {
case "DEPTH":
return sql`${ensIndexerSchema.domain.canonicalDepth}`;
case "REGISTRATION_TIMESTAMP":
return sql`${ensIndexerSchema.registration.start}`;
return sql`${ensIndexerSchema.domain.__latestRegistrationStart}`;
case "REGISTRATION_EXPIRY":
return sql`${ensIndexerSchema.registration.expiry}`;
return sql`${ensIndexerSchema.domain.__latestRegistrationExpiry}`;
}
}

/**
* Whether the ORDER BY for this column needs an explicit NULLS LAST clause.
*
* The registration sort columns (`Domain.__latestRegistration*`) materialize an infinity sentinel
* (see `REGISTRATION_SORT_SENTINEL`) in place of an absent value, so they're NOT NULL — there are no
* NULLs to sort last, and a plain `(registry_id, <col>, id)` composite serves both directions.
* NAME / DEPTH columns are nullable and keep NULLS LAST.
*/
export function shouldUseNullsLast(orderBy: typeof DomainsOrderBy.$inferType): boolean {
return orderBy === "NAME" || orderBy === "DEPTH";
}

/**
* Build a cursor filter for keyset pagination on findDomains results.
*
Expand Down Expand Up @@ -105,7 +117,9 @@ export function cursorFilter(
const idCmp = sql`${ensIndexerSchema.domain.id} ${op} ${cursor.id}`;

// NULL cursor values need explicit handling because Postgres tuple comparison with NULL yields
// NULL/unknown. With NULLS LAST ordering, non-NULL values come before NULL values.
// NULL/unknown. Reached only for NAME/DEPTH (whose columns are nullable, NULLS LAST); registration
// sort columns are sentinel-backed NOT NULL, so their cursor value is never null. With NULLS LAST,
// non-NULL values come before NULL values.
if (cursor.value === null) {
Comment thread
shrugs marked this conversation as resolved.
return direction === "after"
? sql`(${orderColumn} IS NULL AND ${idCmp})`
Expand All @@ -124,6 +138,7 @@ export function cursorFilter(
return sql`${cursor.value}::int`;
case "REGISTRATION_TIMESTAMP":
case "REGISTRATION_EXPIRY":
// ponder bigints are numeric(78,0)
return sql`${cursor.value}::numeric(78,0)`;
}
})();
Expand All @@ -150,11 +165,13 @@ export function orderFindDomains(
const effectiveDesc = isEffectiveDesc(orderDir, inverted);
const orderColumn = getOrderColumn(orderBy);

// Always use NULLS LAST so unregistered domains (NULL registration fields)
// appear at the end regardless of sort direction
const primaryOrder = effectiveDesc
? sql`${orderColumn} DESC NULLS LAST`
: sql`${orderColumn} ASC NULLS LAST`;
const primaryOrder = shouldUseNullsLast(orderBy)
? effectiveDesc
? sql`${orderColumn} DESC NULLS LAST`
: sql`${orderColumn} ASC NULLS LAST`
: effectiveDesc
? sql`${orderColumn} DESC`
: sql`${orderColumn} ASC`;

const { ensIndexerSchema } = di.context;
// Always include id as tiebreaker for stable ordering
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import type { NormalizedAddress, RegistryId } from "enssdk";

import di from "@/di";
import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span";
import { makeLogger } from "@/lib/logger";
import type { Context } from "@/omnigraph-api/context";
import { DomainCursors } from "@/omnigraph-api/lib/find-domains/domain-cursor";
import {
cursorFilter,
Expand All @@ -15,12 +13,11 @@ import {
} from "@/omnigraph-api/lib/find-domains/find-domains-resolver-helpers";
import type { DomainOrderValue } from "@/omnigraph-api/lib/find-domains/types";
import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection";
import { rejectAnyErrors } from "@/omnigraph-api/lib/reject-any-errors";
import {
PAGINATION_DEFAULT_MAX_SIZE,
PAGINATION_DEFAULT_PAGE_SIZE,
} from "@/omnigraph-api/schema/constants";
import { type Domain, DomainInterfaceRef } from "@/omnigraph-api/schema/domain";
import type { Domain } from "@/omnigraph-api/schema/domain";
import type {
DomainsNameFilter,
DomainsOrderInput,
Expand All @@ -31,7 +28,6 @@ import type { ENSProtocolVersion } from "@/omnigraph-api/schema/ens-protocol-ver
type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue };

const tracer = trace.getTracer("find-domains");
const logger = makeLogger("find-domains");

const DOMAINS_DEFAULT_ORDER = { by: "NAME", dir: "ASC" } satisfies DomainsOrderValue;

Expand Down Expand Up @@ -93,36 +89,27 @@ function getDefaultOrder(where: DomainsWhere | undefined | null): DomainsOrderVa
}

/**
* GraphQL API resolver for domain connection queries. Builds a single flat SELECT over
* `domains` with conditional joins (parent registry / registration) driven by the supplied
* `where` filters and ordering. Handles cursor-based pagination, ordering, and dataloader
* loading. Used by `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains`.
* GraphQL API resolver for domain connection queries. Handles cursor-based pagination and ordering.
* Used by `Query.domains`, `Account.domains`, `Registry.domains`, and `Domain.subdomains`.
*
* @param context - The GraphQL Context, required for Dataloader access
* @param args - Compound `where` filter, optional ordering, and relay connection args
*/
export function resolveFindDomains(
context: Context,
{
where,
order,
...connectionArgs
}: {
where?: DomainsWhere | null;
order?: typeof DomainsOrderInput.$inferInput | null;
first?: number | null;
last?: number | null;
before?: string | null;
after?: string | null;
},
) {
export function resolveFindDomains({
where,
order,
...connectionArgs
}: {
where?: DomainsWhere | null;
order?: typeof DomainsOrderInput.$inferInput | null;
first?: number | null;
last?: number | null;
before?: string | null;
after?: string | null;
}) {
const defaultOrder = getDefaultOrder(where);
const orderBy = order?.by ?? defaultOrder.by;
const orderDir = order?.dir ?? defaultOrder.dir;

const needsRegistrationJoin =
orderBy === "REGISTRATION_TIMESTAMP" || orderBy === "REGISTRATION_EXPIRY";

const { ensIndexerSchema } = di.context;

const filterConditions = and(
Expand Down Expand Up @@ -173,102 +160,36 @@ export function resolveFindDomains(
const beforeCursor = before ? DomainCursors.decode(before) : undefined;
const afterCursor = after ? DomainCursors.decode(after) : undefined;

// SELECT only `id` plus the active order column when it requires a JOIN. NAME/DEPTH
// order values are read back from the dataloader-hydrated Domain — for those orderings
// the keyset query stays narrow enough for an index-only scan against the composite
// indexes on `domains`.
const registrationValueColumn = (() => {
switch (orderBy) {
case "REGISTRATION_TIMESTAMP":
return ensIndexerSchema.registration.start;
case "REGISTRATION_EXPIRY":
return ensIndexerSchema.registration.expiry;
default:
return sql<bigint | null>`NULL`.as("registration_value");
}
})();

const { ensDb } = di.context;
let query = ensDb
.select({
id: ensIndexerSchema.domain.id,
registrationValue: registrationValueColumn,
})
.from(ensIndexerSchema.domain)
.$dynamic();

if (needsRegistrationJoin) {
query = query
.leftJoin(
ensIndexerSchema.latestRegistrationIndex,
eq(ensIndexerSchema.latestRegistrationIndex.domainId, ensIndexerSchema.domain.id),
)
.leftJoin(
ensIndexerSchema.registration,
and(
eq(ensIndexerSchema.registration.domainId, ensIndexerSchema.domain.id),
eq(
ensIndexerSchema.registration.registrationIndex,
ensIndexerSchema.latestRegistrationIndex.registrationIndex,
),
),
);
}

const finalQuery = query
.where(
and(
filterConditions,
beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined,
afterCursor ? cursorFilter(afterCursor, orderBy, orderDir, "after") : undefined,
),
)
.orderBy(...orderClauses)
.limit(limit);

logger.debug({ sql: finalQuery.toSQL() });
const query = ensDb.query.domain.findMany({
where: and(
filterConditions,
beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined,
afterCursor ? cursorFilter(afterCursor, orderBy, orderDir, "after") : undefined,
),
orderBy: orderClauses,
limit,
with: { label: true },
});

const results = await withActiveSpanAsync(
const domains = await withActiveSpanAsync(
tracer,
"find-domains.connection",
{ orderBy, orderDir, limit },
() => finalQuery.execute(),
() => query.execute(),
);

const loadedDomains = await withActiveSpanAsync(
tracer,
"find-domains.dataloader",
{ count: results.length },
() =>
rejectAnyErrors(
DomainInterfaceRef.getDataloader(context).loadMany(
results.map((result) => result.id),
),
),
);

const registrationValueById = needsRegistrationJoin
? new Map(results.map((r) => [r.id, r.registrationValue ?? null]))
: null;

return loadedDomains.map((domain): DomainWithOrderValue => {
return domains.map((domain): DomainWithOrderValue => {
const __orderValue: DomainOrderValue = (() => {
switch (orderBy) {
case "NAME":
return truncateNameForCursor(domain.canonicalName);
case "DEPTH":
return domain.canonicalDepth;
case "REGISTRATION_TIMESTAMP":
return domain.__latestRegistrationStart;
case "REGISTRATION_EXPIRY":
// `registrationValueById` is populated iff `needsRegistrationJoin` is true,
// which is exactly the REGISTRATION_* arms here. `loadedDomains` is keyed by
// the same ids as `results`, so the lookup is guaranteed to hit.
if (registrationValueById === null) {
throw new Error(
`Invariant: registrationValueById should be populated when orderBy=${orderBy}`,
);
}
return registrationValueById.get(domain.id) ?? null;
return domain.__latestRegistrationExpiry;
}
})();
return { ...domain, __orderValue };
Expand Down
12 changes: 8 additions & 4 deletions apps/ensapi/src/omnigraph-api/schema/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
RESOLVE_ACCELERATE_ARG,
} from "@/omnigraph-api/schema/constants";
import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain";
import { AccountDomainsWhereInput, DomainsOrderInput } from "@/omnigraph-api/schema/domain-inputs";
import {
AccountDomainsWhereInput,
DOMAINS_ORDERING_DESCRIPTION,
DomainsOrderInput,
} from "@/omnigraph-api/schema/domain-inputs";
import { EventRef } from "@/omnigraph-api/schema/event";
import { AccountEventsWhereInput } from "@/omnigraph-api/schema/event-inputs";
import { PermissionsUserRef } from "@/omnigraph-api/schema/permissions";
Expand Down Expand Up @@ -115,14 +119,14 @@ AccountRef.implement({
// Account.domains
////////////////////
domains: t.connection({
description: "The Domains that are owned by the Account.",
description: `The Domains that are owned by the Account. ${DOMAINS_ORDERING_DESCRIPTION}`,
type: DomainInterfaceRef,
args: {
where: t.arg({ type: AccountDomainsWhereInput }),
order: t.arg({ type: DomainsOrderInput }),
},
resolve: (parent, { where, order, ...connectionArgs }, context) =>
resolveFindDomains(context, {
resolve: (parent, { where, order, ...connectionArgs }) =>
resolveFindDomains({
where: { ...where, ownerId: parent.id },
order,
...connectionArgs,
Expand Down
27 changes: 25 additions & 2 deletions apps/ensapi/src/omnigraph-api/schema/domain-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,35 @@ export const SubdomainsWhereInput = builder.inputType("SubdomainsWhereInput", {
//////////////////////

export const DomainsOrderBy = builder.enumType("DomainsOrderBy", {
description: "Fields by which domains can be ordered",
values: ["NAME", "DEPTH", "REGISTRATION_TIMESTAMP", "REGISTRATION_EXPIRY"] as const,
description: "Fields by which domains can be ordered.",
values: {
NAME: { description: "Order by the Domain's Canonical Name, alphabetically." },
Comment thread
shrugs marked this conversation as resolved.
DEPTH: {
description: "Order by Canonical Name depth (number of labels); e.g. `eth` < `vitalik.eth`.",
},
REGISTRATION_TIMESTAMP: {
description:
"Order by the start time of the Domain's latest Registration. A Domain with no Registration has no timestamp and sorts last when `dir: ASC` (“oldest registered first”) and first when `dir: DESC` (“most recently registered first”).",
},
REGISTRATION_EXPIRY: {
description:
"Order by the expiry of the Domain's latest Registration. A Domain that never expires (or has no Registration) is treated as +∞: it sorts last when `dir: ASC` (“expiring soonest first”) and first when `dir: DESC` (“expiring latest first”).",
},
},
});

export type DomainsOrderByValue = typeof DomainsOrderBy.$inferType;

/**
* Shared description fragment documenting ordering / NULL-placement behavior, appended to every
* find-domains-based connection field (Query.domains, Account.domains, Registry.domains,
* Domain.subdomains). Kept in one place so the semantics are documented exactly once.
*/
export const DOMAINS_ORDERING_DESCRIPTION =
"Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or " +
"REGISTRATION_EXPIRY, Domains lacking that value (no Registration, or a never-expiring Registration " +
"treated as +∞) sort last when `dir: ASC` and first when `dir: DESC`.";
Comment thread
shrugs marked this conversation as resolved.
Outdated

export const DomainsOrderInput = builder.inputType("DomainsOrderInput", {
description: "Ordering options for domains query. If no order is provided, the default is ASC.",
fields: (t) => ({
Expand Down
7 changes: 4 additions & 3 deletions apps/ensapi/src/omnigraph-api/schema/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from "@/omnigraph-api/schema/constants";
import { DomainCanonicalRef } from "@/omnigraph-api/schema/domain-canonical";
import {
DOMAINS_ORDERING_DESCRIPTION,
DomainPermissionsWhereInput,
DomainsOrderInput,
SubdomainsWhereInput,
Expand Down Expand Up @@ -279,16 +280,16 @@ DomainInterfaceRef.implement({
// Domain.subdomains
/////////////////////
subdomains: t.connection({
description: "All Domains that are direct descendants of this Domain in the namegraph.",
description: `All Domains that are direct descendants of this Domain in the namegraph. ${DOMAINS_ORDERING_DESCRIPTION}`,
type: DomainInterfaceRef,
args: {
where: t.arg({ type: SubdomainsWhereInput }),
order: t.arg({ type: DomainsOrderInput }),
},
resolve: (parent, { where, order, ...connectionArgs }, context) => {
resolve: (parent, { where, order, ...connectionArgs }) => {
if (!parent.subregistryId) return EMPTY_CONNECTION;

return resolveFindDomains(context, {
return resolveFindDomains({
where: { ...where, registryId: parent.subregistryId },
order,
...connectionArgs,
Expand Down
7 changes: 4 additions & 3 deletions apps/ensapi/src/omnigraph-api/schema/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AccountByInput, AccountRef } from "@/omnigraph-api/schema/account";
import { ID_PAGINATED_CONNECTION_ARGS } from "@/omnigraph-api/schema/constants";
import { DomainInterfaceRef } from "@/omnigraph-api/schema/domain";
import {
DOMAINS_ORDERING_DESCRIPTION,
DomainIdInput,
DomainsOrderInput,
DomainsWhereInput,
Expand Down Expand Up @@ -106,14 +107,14 @@ builder.queryType({
// Find Domains
////////////////
domains: t.connection({
description: "Find Canonical Domains by Name.",
description: `Find Canonical Domains by Name. ${DOMAINS_ORDERING_DESCRIPTION}`,
type: DomainInterfaceRef,
args: {
where: t.arg({ type: DomainsWhereInput, required: true }),
order: t.arg({ type: DomainsOrderInput }),
},
resolve: (_, { where, order, ...connectionArgs }, context) =>
resolveFindDomains(context, { where, order, ...connectionArgs }),
resolve: (_, { where, order, ...connectionArgs }) =>
resolveFindDomains({ where, order, ...connectionArgs }),
}),

//////////////////////////////////
Expand Down
Loading
Loading