Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/recently-registered-subdomains-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@ensnode/ensdb-sdk": patch
"ensindexer": patch
"ensapi": patch
Comment thread
shrugs marked this conversation as resolved.
Outdated
---

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,25 @@ 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 this is a registration ordering, whose sort columns (`Domain.__latestRegistration*`) are
* sentinel-backed and NOT NULL (see `REGISTRATION_SORT_SENTINEL`).
*
* Because those columns never hold NULL, the ORDER BY omits any NULLS clause so a single plain
* `(registry_id, <col>, id)` composite serves both directions (ASC forward / DESC backward) with a
* plain keyset tuple. The sentinel sorts last for ASC ("oldest" / "expiring soonest") and first for
* DESC. NAME / DEPTH columns are nullable and keep their NULLS-LAST behavior.
*/
function isRegistrationOrdering(orderBy: typeof DomainsOrderBy.$inferType): boolean {
Comment thread
shrugs marked this conversation as resolved.
Outdated
return orderBy === "REGISTRATION_TIMESTAMP" || orderBy === "REGISTRATION_EXPIRY";
}

/**
* Build a cursor filter for keyset pagination on findDomains results.
*
Expand Down Expand Up @@ -105,7 +118,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 +139,11 @@ export function cursorFilter(
return sql`${cursor.value}::int`;
case "REGISTRATION_TIMESTAMP":
case "REGISTRATION_EXPIRY":
// Ponder's `t.bigint()` columns are `numeric(78,0)` (they hold EVM uint256 values, e.g. the
Comment thread
shrugs marked this conversation as resolved.
Outdated
// uint64-max "never expires" expiry sentinel), so the materialized `__latestRegistration*`
// columns are numeric too. Cast the cursor value to the same type: it matches the column
// exactly (no `col::…` coercion) so the keyset tuple compare stays an Index Cond, and it
// avoids the `::bigint` overflow on values beyond int8 range.
return sql`${cursor.value}::numeric(78,0)`;
}
})();
Expand All @@ -150,11 +170,17 @@ 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`;
// Registration sort columns are sentinel-backed NOT NULL, so the ORDER BY omits any NULLS clause —
// that lets the plain `(registry_id, <col>, id)` composite serve both directions (ASC forward /
// DESC backward); the sentinel (+∞) sorts last for ASC and first for DESC. NAME / DEPTH columns
// are nullable and keep NULLS LAST in both directions.
Comment thread
shrugs marked this conversation as resolved.
Outdated
const primaryOrder = isRegistrationOrdering(orderBy)
? effectiveDesc
? sql`${orderColumn} DESC`
: sql`${orderColumn} ASC`
: effectiveDesc
? sql`${orderColumn} DESC NULLS LAST`
: sql`${orderColumn} ASC NULLS LAST`;

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 @@ -6,7 +6,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 +14,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 Down Expand Up @@ -93,36 +91,29 @@ 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. Builds a single flat SELECT over `domains`
* (filters and ordering both resolve against `domains` columns) and hydrates fully-formed Domain
Comment thread
shrugs marked this conversation as resolved.
Outdated
* rows in keyset order. 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,84 +164,32 @@ 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");
}
})();

// Hydrate Domains directly: every order value lives on `domains` and the only eagerly
// loaded relation is `label`, so a single relational query (mirroring the Domain
// dataloader's `with: { label: true }`) returns fully-formed Domain rows in keyset order —
// no second round-trip through the dataloader. The keyset/order scan still rides the
// `domains` composite indexes; `label` is joined only for the `LIMIT`ed rows.
Comment thread
shrugs marked this conversation as resolved.
Outdated
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);
const finalQuery = ensDb.query.domain.findMany({
Comment thread
shrugs marked this conversation as resolved.
Outdated
where: and(
filterConditions,
beforeCursor ? cursorFilter(beforeCursor, orderBy, orderDir, "before") : undefined,
afterCursor ? cursorFilter(afterCursor, orderBy, orderDir, "after") : undefined,
),
orderBy: orderClauses,
limit,
with: { label: true },
});

logger.debug({ sql: finalQuery.toSQL() });

Comment thread
shrugs marked this conversation as resolved.
Outdated
const results = await withActiveSpanAsync(
const loadedDomains = await withActiveSpanAsync(
Comment thread
shrugs marked this conversation as resolved.
Outdated
tracer,
"find-domains.connection",
{ orderBy, orderDir, limit },
() => finalQuery.execute(),
() => finalQuery,
);
Comment thread
shrugs marked this conversation as resolved.
Outdated

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 => {
const __orderValue: DomainOrderValue = (() => {
switch (orderBy) {
Expand All @@ -259,16 +198,9 @@ export function resolveFindDomains(
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
Loading
Loading