Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -20,12 +20,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 @@ -67,7 +79,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 @@ -84,6 +98,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 @@ -110,11 +125,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,21 +5,18 @@ 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,
orderFindDomains,
} 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 @@ -30,7 +27,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 @@ -92,36 +88,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 @@ -172,102 +159,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 domain.__canonicalNamePrefix;
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
28 changes: 26 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,36 @@ 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 for REGISTRATION_TIMESTAMP; no " +
"Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when " +
"`dir: ASC` and first when `dir: DESC`.";

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