Skip to main content
doc0blade47/dubdocumentation home
Sign in

Caching & Rate Limiting

Last updated Oct 12, 202674% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Dub uses Upstash Redis and Vector infrastructure to power its caching, rate limiting, and event streaming architecture across distributed serverless and edge environments. By leveraging sliding-window rate limiting policies, multi-tiered link resolution caches, and append-only event streams, the system minimizes database load during traffic spikes while maintaining strict consistency across workspaces and domains.

Sources: apps/web/lib/upstash/redis.ts:1-7, apps/web/lib/upstash/ratelimit.ts:5-20, apps/web/lib/api/links/cache.ts:18-21, apps/web/lib/upstash/redis-streams/client.ts:8-13

Upstash Client Configuration

Redis Client Initialization

The application initializes multiple Upstash Redis clients using REST connection parameters drawn from environment variables. The primary redis instance connects via UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. A separate global Redis configuration checks for the presence of UPSTASH_GLOBAL_REDIS_REST_URL and UPSTASH_GLOBAL_REDIS_REST_TOKEN. If both global variables are defined, redisGlobal targets the global endpoints; otherwise, it falls back to the standard primary environment variables. A third client, redisGlobalWithTimeout, extends the global configuration by supplying an explicit timeout signal via AbortSignal.timeout(1500).

import { Redis } from "@upstash/redis";
 
// Initiate Redis instance by connecting to REST URL
export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL || "",
  token: process.env.UPSTASH_REDIS_REST_TOKEN || "",
});
 
// This is a separate global Redis instance that we use
// for global operations (e.g. linkCache, recordClick)
// so that if this redis goes down, it won't impact other endpoints
const hasGlobalRedisConfig =
  !!process.env.UPSTASH_GLOBAL_REDIS_REST_URL &&
  !!process.env.UPSTASH_GLOBAL_REDIS_REST_TOKEN;
 
const redisConfig = {
  url: hasGlobalRedisConfig
    ? process.env.UPSTASH_GLOBAL_REDIS_REST_URL
    : process.env.UPSTASH_REDIS_REST_URL || "",
  token: hasGlobalRedisConfig
    ? process.env.UPSTASH_GLOBAL_REDIS_REST_TOKEN
    : process.env.UPSTASH_REDIS_REST_TOKEN || "",
};
 
export const redisGlobal = new Redis(redisConfig);
 
export const redisGlobalWithTimeout = new Redis({
  ...redisConfig,
  signal: () => AbortSignal.timeout(1500),
});

Sources: apps/web/lib/upstash/redis.ts:1-30

Note

The redisGlobalWithTimeout instance enforces a strict 1500ms timeout on operations using AbortSignal.timeout(1500), preventing stalled global operations from blocking execution endpoints.

Vector Index Initialization

Vector operations rely on two distinct Upstash Vector Index instances initialized with dedicated REST credentials. The standard vectorIndex connects using UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN. The emojiVectorIndex targets emoji-specific vector embeddings by utilizing UPSTASH_VECTOR_EMOJI_REST_URL and UPSTASH_VECTOR_EMOJI_REST_TOKEN, typing its metadata entries with strict emoji and label string properties.

import { Index } from "@upstash/vector";
 
export const vectorIndex = new Index({
  url: process.env.UPSTASH_VECTOR_REST_URL ?? "",
  token: process.env.UPSTASH_VECTOR_REST_TOKEN ?? "",
});
 
export const emojiVectorIndex = new Index<{
  emoji: string;
  label: string;
}>({
  url: process.env.UPSTASH_VECTOR_EMOJI_REST_URL ?? "",
  token: process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN ?? "",
});

Sources: apps/web/lib/upstash/vector.ts:1-14

Key Derivation and Multi-Tiered Lookup

The link resolution cache relies on a multi-tiered architecture combining an in-memory LRUCache, a distributed redisGlobal data store, and a Vercel runtime cache fallback. Cache keys for link lookups are generated via _createKey, which evaluates domain case sensitivity. If a domain is case-sensitive, the key is decoded using decodeKey; otherwise, keys are forced to lowercase with a linkcache:{domain}:{originalKey} prefix.

const linkLRUCache = new LRUCache<string, RedisLinkProps>({
  max: 10000, // max 10,000 entries
  ttl: 5000, // 5 seconds
});

Sources: apps/web/lib/api/links/cache.ts:18-21

async get({ domain, key }: Pick<LinkProps, "domain" | "key">): Promise<{
  cachedLink: RedisLinkProps | null;
  redisFailOver: boolean;
}> {
  // here we use linkcache:${domain}:${key} instead of this._createKey({ domain, key })
  // because the key can either be cached as case-sensitive or case-insensitive depending on the domain
  // so we should get the original key from the cache
  const cacheKey = `linkcache:${domain}:${key}`;

Sources: apps/web/lib/api/links/cache.ts:71-79

_createKey({ domain, key }: Pick<LinkProps, "domain" | "key">) {
  const caseSensitive = isCaseSensitiveDomain(domain);
  const originalKey = caseSensitive ? decodeKey(key) : key;
  const cacheKey = `linkcache:${domain}:${originalKey}`;
 
  return caseSensitive ? cacheKey : cacheKey.toLowerCase();
}

Sources: apps/web/lib/api/links/cache.ts:173-179

Warning

When retrieving links, lookups use linkcache:${domain}:${key} instead of _createKey directly. Because keys can be cached as case-sensitive or case-insensitive depending on the domain configuration, this format retrieves the exact original key stored in the cache.

Serialization and Expiration Constants

Link objects are formatted into serialized Redis payloads before storage. Cache expirations differ across storage layers to balance durability and freshness: the local LRU cache holds up to 10,000 entries with a 5-second TTL, the Vercel runtime cache expires after 5 minutes (300 seconds), and the global Redis cache persists records for 24 hours.

Cache TierStorage MechanismExpiration / LimitSources
LRU CachelinkLRUCache (lru-cache)Max 10,000 entries, 5-second TTL (5000)apps/web/lib/api/links/cache.ts:18-21
Vercel Runtime CachevercelCache (@vercel/functions)5 minutes (60 * 5)apps/web/lib/api/links/cache.ts:27-29
Global Redis CacheredisGlobal (@upstash/redis)24 hours (60 * 60 * 24)apps/web/lib/api/links/cache.ts:30

Sources: apps/web/lib/api/links/cache.ts:18-30

Invalidation and Failover Mechanisms

Cache invalidation occurs through revalidateTag combined with static page tags formatted as static:{domain}:{key} and static:{domain}. When invalidating entries in the Vercel runtime cache, the _invalidateVercelRuntimeCache method checks if a value is cached before deleting it to optimize operations, as runtime cache reads are significantly cheaper than writes.

_createStaticPagesCacheKeys({
  domain,
  key,
}: Pick<LinkProps, "domain" | "key">) {
  domain = domain.toLowerCase();
  // here we set 2 cache tags to invalidate the cache:
  // 1. static:${domain}:${key} - for the specific static page
  // 2. static:${domain} - scope all links under the domain (for easy purging in PATCH /domains/:domain)
  return `static:${domain}:${key},static:${domain}`;
}

Sources: apps/web/lib/api/links/cache.ts:181-190

private async _invalidateVercelRuntimeCache(cacheKey: string) {
  return vercelCache
    .get(cacheKey)
    .then((cachedLink) =>
      cachedLink ? vercelCache.delete(cacheKey) : undefined,
    );
}

Sources: apps/web/lib/api/links/cache.ts:194-200

Note

If redisGlobalWithTimeout throws an error during lookups, the system triggers a failover sequence: it queries the Vercel runtime cache, falls back to getLinkViaEdge querying MySQL if missing, and asynchronously repopulates both the LRU and Vercel caches using waitUntil.

Sources: apps/web/lib/api/links/cache.ts:105-135

Rate Limiting Engine Policies

Algorithm Setup and Policy Tiers

The rate limiting engine uses @upstash/ratelimit configured with a sliding window algorithm and a default capacity of 10 requests per 10 seconds. The base ratelimit helper initializes the @upstash/ratelimit instance using the shared Redis client, enabling analytics, a request timeout of 1000ms, and a dub key prefix.

export const ratelimit = (
  requests: number = 10,
  seconds:
    | `${number} ms`
    | `${number} s`
    | `${number} m`
    | `${number} h`
    | `${number} d` = "10 s",
) => {
  return new Ratelimit({
    redis: redis,
    limiter: Ratelimit.slidingWindow(requests, seconds),
    analytics: true,
    prefix: "dub",
    timeout: 1000,
  });

Sources: apps/web/lib/upstash/ratelimit.ts:5-20

Predefined rate limit policies define exact attempt counts, time windows, and key prefixes for various sensitive operations. Policies are enforced via RATELIMIT_POLICIES, which maps identifiers like login attempts, file uploads, and search routines to strict quotas.

Policy NameAttemptsWindowKey PrefixSources
login51 mrl:auth:loginapps/web/lib/upstash/ratelimit-policies.ts:19-24
loginLinkSend21 mrl:auth:login-link:sendapps/web/lib/upstash/ratelimit-policies.ts:26-30
signupOtpSend21 mrl:auth:signup:otp:sendapps/web/lib/upstash/ratelimit-policies.ts:32-36
accountExistsCheck81 mrl:auth:account-exists:checkapps/web/lib/upstash/ratelimit-policies.ts:39-42
passwordResetRequest21 mrl:auth:password-reset:requestapps/web/lib/upstash/ratelimit-policies.ts:44-48
passwordReset21 mrl:auth:password-reset:confirmapps/web/lib/upstash/ratelimit-policies.ts:50-54
emailChangeRequest324 hrl:auth:email-changeapps/web/lib/upstash/ratelimit-policies.ts:56-60
emailChangeRequestTarget324 hrl:auth:email-change:targetapps/web/lib/upstash/ratelimit-policies.ts:63-67
samlVerify101 mrl:auth:saml-verifyapps/web/lib/upstash/ratelimit-policies.ts:69-73
domainSearchAvailability15 srl:domains:search-availabilityapps/web/lib/upstash/ratelimit-policies.ts:339-344
emojiSearch3010 srl:ai:emoji-searchapps/web/lib/upstash/ratelimit-policies.ts:346-351

Sources: apps/web/lib/upstash/ratelimit-policies.ts:18-352

Request Enforcement via assertRateLimit

The assertRateLimit function evaluates incoming requests against a given RatelimitPolicy. It skips enforcement if shouldApplyRateLimit evaluates to false, constructs the Redis key by joining the policy.keyPrefix and dynamic identifier segments, and queries the sliding window limiter.

export async function assertRateLimit({
  policy,
  identifier,
}: {
  policy: RatelimitPolicy;
  identifier: string | string[]; // dynamic part(s) of the key, e.g. email or [email, ip]
}) {
  if (!shouldApplyRateLimit) {
    return;
  }
 
  const key = [
    policy.keyPrefix,
    ...(Array.isArray(identifier) ? identifier : [identifier]),
  ].join(":");
 
  const { success, reset } = await ratelimit(
    policy.attempts,
    policy.window,
  ).limit(key);

Sources: apps/web/lib/upstash/assert-rate-limit.ts:28-47

If the request exceeds permitted attempts, assertRateLimit computes a human-friendly duration via formatRetryAfter and throws a DubApiError with the rate_limit_exceeded code and policy-specific error message.

if (!success) {
  const retryAfter = formatRetryAfter(reset);
 
  const message =
    typeof policy.message === "function"
      ? policy.message({
          retryAfter,
          attempts: policy.attempts,
          window: policy.window,
        })
      : policy.message ??
        `Too many requests. Please try again in ${retryAfter}.`;
 
  throw new DubApiError({
    code: "rate_limit_exceeded",
    message,
  });
}

Sources: apps/web/lib/upstash/assert-rate-limit.ts:49-66

Warning

The login policy defines a custom error message string ("too-many-login-attempts") that is matched verbatim by the sign-in page. Modifying this string breaks error handling in the authentication UI.

Sources: apps/web/lib/upstash/ratelimit-policies.ts:19-24

Alternative enforcement occurs in edge routes like Unsplash search, which directly invokes ratelimit(10, "10 s").limit(...) using the caller's IP address from @vercel/functions and responds with HTTP status 429 upon failure.

const ip = ipAddress(req);
const { success } = await ratelimit(10, "10 s").limit(`unsplash:${ip}`);
if (!success) {
  return new Response("Don't DDoS me pls 🥺", { status: 429 });
}

Sources: apps/web/app/api/unsplash/search/route.ts:20-24

Distributed Concurrency Locks

export async function withRedisLock<T>({
  key,
  ttlSeconds,
  fn,
}: {
  key: string;
  ttlSeconds: number;
  fn: () => Promise<T>;
}): Promise<T | null> {
  const token = crypto.randomUUID();
  const acquired = await redis.set(key, token, {
    nx: true,
    ex: ttlSeconds,
  });
 
  if (!acquired) {
    return null;
  }
 
  try {
    return await fn();
  } finally {
    // Lua compare-and-delete: only the token owner may release the lock.
    await redis.eval(RELEASE_LOCK_SCRIPT, [key], [token]);
  }
}

Sources: apps/web/lib/upstash/redis-lock.ts:17-42

The withRedisLock function implements mutual exclusion by executing critical-section routines under an Upstash Redis NX lock. It generates a unique identifier using crypto.randomUUID() and attempts to set the lock key with { nx: true, ex: ttlSeconds }. If acquisition fails because another holder is active, the function immediately returns null without running the wrapped callback.

const RELEASE_LOCK_SCRIPT = `
  if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
  else
    return 0
  end
`;

Sources: apps/web/lib/upstash/redis-lock.ts:3-9

To prevent race conditions where an expired lock acquired by another runner is mistakenly deleted, the finally block evaluates a Lua compare-and-delete script. This script verifies that the stored value matches the original execution token before executing redis.call("del", KEYS[1]).

Caution

Releasing a Redis lock without verifying the unique token risks deleting a lock belonging to a different process whose execution exceeded the original ttlSeconds threshold.

Sources: apps/web/lib/upstash/redis-lock.ts:14-15

Application Domain Entity Caching

Workspace, Onboarding, and Embed Token Caches

Dub implements specialized ephemeral caching layers for domain entities using Upstash Redis. These caches reduce database load for frequently accessed workspace metadata, onboarding progression, authentication tokens, tracking hostnames, and third-party checkout flows.

Workspace products are cached using keys prefixed with workspace:product for a duration of 30 days. The WorkspaceProductCache class handles retrieval, persistence, and deletion of DubProduct records indexed by workspace slug.

const CACHE_KEY_PREFIX = "workspace:product";
const CACHE_EXPIRATION = 60 * 60 * 24 * 30; // 30 days
 
class WorkspaceProductCache {
  async get({ slug }: { slug: string }) {
    return await redis.get<DubProduct>(this._createKey({ slug }));
  }
 
  async set({ slug, product }: { slug: string; product: DubProduct }) {
    return await redis.set(this._createKey({ slug }), product, {
      ex: CACHE_EXPIRATION,
    });
  }
 
  async delete({ slug }: { slug: string }) {
    return await redis.del(this._createKey({ slug }));
  }
 
  _createKey({ slug }: { slug: string }) {
    return `${CACHE_KEY_PREFIX}:${slug}`;
  }
}

Sources: apps/web/lib/api/workspaces/workspace-product-cache.ts:4-25

Onboarding progression states are tracked via OnboardingStepCache under the onboarding-step prefix with a 24-hour expiration window. It supports single-user operations and batched pipeline updates across multiple user identifiers.

const CACHE_KEY_PREFIX = "onboarding-step";
export const ONBOARDING_WINDOW_SECONDS = 60 * 60 * 24; // 24 hours
 
class OnboardingStepCache {
  async set({ userId, step }: { userId: string; step: OnboardingStep }) {
    return await redis.set(`${CACHE_KEY_PREFIX}:${userId}`, step, {
      ex: ONBOARDING_WINDOW_SECONDS,
    });
  }
 
  async mset({ userIds, step }: { userIds: string[]; step: OnboardingStep }) {
    const pipeline = redis.pipeline();
    userIds.forEach((userId) => {
      pipeline.set(`${CACHE_KEY_PREFIX}:${userId}`, step, {
        ex: ONBOARDING_WINDOW_SECONDS,
      });
    });
    return await pipeline.exec();
  }
 
  async get({ userId }: { userId: string }) {
    return await redis.get(`${CACHE_KEY_PREFIX}:${userId}`);
  }
}

Sources: apps/web/lib/api/workspaces/onboarding-step-cache.ts:4-27

Embed tokens for referrals are generated with a unique identifier prefix and stored with an NX condition to prevent collision.

class ReferralsEmbedToken {
  async create(props: ReferralsEmbedTokenProps) {
    const publicToken = createId({
      prefix: EMBED_PUBLIC_TOKEN_PREFIX,
    });
 
    await redis.set(publicToken, JSON.stringify(props), {
      ex: EMBED_PUBLIC_TOKEN_EXPIRY,
      nx: true,
    });
 
    return {
      publicToken,
      expires: new Date(Date.now() + EMBED_PUBLIC_TOKEN_EXPIRY * 1000),
    };
  }
 
  async get(token: string) {
    return await redis.get<ReferralsEmbedTokenProps>(token);
  }
}

Sources: apps/web/lib/embed/referrals/token-class.ts:13-33

Cache ManagerKey PrefixTTLPrimary Data Model
workspaceProductCacheworkspace:product30 daysDubProduct
onboardingStepCacheonboarding-step24 hoursOnboardingStep
tokenCachedubTokenCache24 hoursTokenCacheItem
allowedHostnamesCacheallowedHostnamesCache7 daysstring (joined hostnames)
shopifyCheckoutCacheshopify:checkout:1 hourShopifyCheckoutCacheItem

Sources: apps/web/lib/api/workspaces/workspace-product-cache.ts:4-5, apps/web/lib/api/workspaces/onboarding-step-cache.ts:4-5, apps/web/lib/auth/token-cache.ts:4-5, apps/web/lib/analytics/allowed-hostnames-cache.ts:3-4, apps/web/lib/integrations/shopify/checkout-cache.ts:6-7

Authentication Token and Hostname Caches

Restricted API tokens and legacy personal access tokens are cached via TokenCache using the dubTokenCache prefix and a 24-hour expiration. Retrieved items are validated against the tokenCacheItemSchema Zod definition before use, ensuring type safety for embedded user objects, project plans, trial dates, and scope strings.

const tokenCacheItemSchema = z.object({
  id: z.string().nullish(),
  expires: z.date().nullish(),
  user: z.object({
    id: z.string(),
    name: z.string().nullable(),
    email: z.string().nullable(),
    isMachine: z.boolean(),
    defaultWorkspace: z.string().nullish(),
    defaultPartnerId: z.string().nullish(),
  }),
  scopes: z.string().nullish(),
  projectId: z.string().nullish(),
  project: z
    .object({
      plan: z.string().nullish(),
      trialEndsAt: z.coerce.date().nullish(),
    })
    .nullish(),
  installationId: z.string().nullish(),
});

Sources: apps/web/lib/auth/token-cache.ts:7-27

When tokens are revoked or modified, expireMany uses an Upstash pipeline to set the TTL of multiple hashed keys to 1 second.

async expireMany({ hashedKeys }: { hashedKeys: string[] }) {
  if (hashedKeys.length === 0) {
    return;
  }
 
  const pipeline = redis.pipeline();
 
  hashedKeys.forEach((hashedKey) => {
    pipeline.expire(this._createKey({ hashedKey }), 1);
  });
 
  return await pipeline.exec();
}

Sources: apps/web/lib/auth/token-cache.ts:57-69

Allowed hostnames for analytics tracking are managed by AllowedHostnamesCache with a 7-day TTL, using batched pipelines for multi-domain sets.

class AllowedHostnamesCache {
  async mset({
    allowedHostnames,
    domains,
  }: {
    allowedHostnames: string;
    domains: string[];
  }) {
    if (domains.length === 0) {
      return;
    }
 
    const pipeline = redis.pipeline();
 
    domains.forEach((domain) => {
      pipeline.set(this._createKey({ domain }), allowedHostnames, {
        ex: CACHE_EXPIRATION,
      });
    });
 
    return await pipeline.exec();
  }

Sources: apps/web/lib/analytics/allowed-hostnames-cache.ts:6-27

Shopify Checkout States and Concurrency Dispatches

Shopify checkouts are cached as Redis hashes under shopify:checkout: with a 1-hour TTL. The ShopifyCheckoutCache parses hash structures through shopifyCheckoutCacheSchema, normalizing nullable click identifiers.

const shopifyCheckoutCacheSchema = z.object({
  clickId: z
    .string()
    .nullish()
    .transform((value) => value ?? undefined),
  workspaceId: z.string().optional(),
  order: shopifyOrderSchema.optional(),
  dispatched: z.boolean().optional(),
});
 
type ShopifyCheckoutCacheItem = z.infer<typeof shopifyCheckoutCacheSchema>;

Sources: apps/web/lib/integrations/shopify/checkout-cache.ts:9-19

The tryDispatchShopifyOrderJob function manages atomic dispatch claims to prevent duplicate processing of the same checkout token. It verifies that the checkout contains an order, workspace ID, and click ID, checks that it has not already been dispatched, and attempts an atomic claim via hsetnx.

if (!checkout.order || !checkout.workspaceId || !checkout.clickId) {
  console.info(
    "Shopify order dispatch skipped: checkout incomplete",
    logContext,
  );
  return false;
}
 
if (checkout.dispatched) {
  console.info(
    "Shopify order dispatch skipped: already dispatched",
    logContext,
  );
  return false;
}
 
// Claim the checkout
const key = shopifyCheckoutCache.createKey(checkoutToken);
const claim = await redis.hsetnx(key, "dispatched", true);
const claimed = Boolean(claim);
 
if (!claimed) {
  console.info("Shopify order dispatch skipped: claim lost", logContext);
  return false;
}

Sources: apps/web/lib/integrations/shopify/checkout-cache.ts:77-101

Warning

If job dispatch fails after acquiring the checkout claim, the catch block explicitly deletes the dispatched hash field to release the lock, ensuring that transient queue failures do not permanently strand orders.

Sources: apps/web/lib/integrations/shopify/checkout-cache.ts:114-121

Stream Ingestion and Consumption

Event Producers and Fallback Pipelines

Dub utilizes append-only Redis streams via the RedisStream abstraction class to buffer high-frequency write events such as link clicks, workspace click webhook events, and workspace links usage updates. This architecture decouples synchronous request handling from heavy database updates by persisting events into streams via redis.xadd and processing them asynchronously in cron routines or batched consumers.

export class RedisStream {
  private streamKey: string;
 
  constructor(streamKey: string) {
    this.streamKey = streamKey;
  }
 
  /**
   * Read and process a batch of items from the stream
   * This provides a consumable batch of messages with the ability to delete them upon successful completion of the handler
   */
  async processBatch<T>(
    handler: (
      records: RedisStreamEntry<T>[],
    ) => Promise<any & { processedEntryIds: string[] }>,
    options: {
      startId?: string;
      endId?: string;
      count?: number;
      deleteAfterRead?: boolean;
    } = {},
  ): Promise<any> {
    const {
      startId = "0",
      endId = "+",
      count = 1000,
      deleteAfterRead = true,
    } = options;
 
    try {
      // Read entries from the stream
      const entriesMap = await redis.xrange(
        this.streamKey,
        startId,
        endId,
        count,
      );
 
      const entries: RedisStreamEntry<T>[] = Object.entries(entriesMap).map(
        ([id, data]) => ({
          id,
          data: data as any,
        }),
      );
 
      const { processedEntryIds, ...response } = await handler(entries);
 
      // Optionally delete processed entries to prevent memory buildup
      if (deleteAfterRead) {
        try {
          if (processedEntryIds.length > 0) {
            // xdel supports deleting multiple IDs at once
            await redis.xdel(this.streamKey, processedEntryIds);
          }
        } catch (error) {
          console.warn("Failed to clean up processed stream entries:", error);
          // Don't throw - this is not critical
        }
      }
 
      return response as Response;
    } catch (error) {
      console.error(
        "Failed to read workspace usage updates from stream:",
        error,
      );
      throw error;
    }
  }

Sources: apps/web/lib/upstash/redis-streams/client.ts:8-76

Event streams are managed by dedicated modules that validate payloads before publishing. The publishLinkClickEvent function pushes link click payloads to link:click:events, capturing metrics for link click counts, workspace usage, and program enrollments. If Redis ingestion fails, it falls back to direct database execution using conn.execute for PlanetScale alongside error reporting via Axiom.

// Publishes a link click event to the stream to update:
// - link clicks count + lastClicked timestamp
// - workspace usage + totalClicks
// - program enrollment totalClicks
export const publishLinkClickEvent = async ({
  linkId,
  timestamp,
  workspaceId,
  programId,
  partnerId,
}: LinkClickEvent) => {
  const payload = {
    linkId,
    timestamp,
    ...(workspaceId && { workspaceId }),
    ...(programId && partnerId && { programId, partnerId }),
  };
 
  try {
    return await redis.xadd(STREAM_KEY, "*", payload);
  } catch (error) {
    logger.error("stream.publish_failed", {
      service: "upstash",
      streamKey: STREAM_KEY,
      error: toErrorFields(error),
      correlation: {
        linkId,
        workspaceId,
        programId,
        partnerId,
      },
    });
 
    return await Promise.allSettled([
      conn.execute(
        "UPDATE Link SET clicks = clicks + 1, lastClicked = NOW() WHERE id = ?",
        [linkId],
      ),
 
      workspaceId
        ? conn.execute(
            "UPDATE Project p SET p.usage = p.usage + 1, p.totalClicks = p.totalClicks + 1 WHERE id = ?",
            [workspaceId],
          )
        : null,
 
      programId && partnerId
        ? conn.execute(
            "UPDATE ProgramEnrollment SET totalClicks = totalClicks + 1 WHERE programId = ? AND partnerId = ?",
            [programId, partnerId],
          )
        : null,
 
      logger.flush(),
    ]);
  }

Sources: apps/web/lib/upstash/redis-streams/link-click-events.ts:18-73

Similarly, publishWorkspaceLinksUsageEvent writes workspace link changes to workspace:linksUsage:updates. When network issues prevent stream publication, it executes a fallback SQL statement updating linksUsage and totalLinks directly on the Project table.

export const publishWorkspaceLinksUsageEvent = async (
  event: WorkspaceLinksUsageEvent,
) => {
  const { workspaceId, linksCount, timestamp } = event;
 
  try {
    return await redis.xadd(WORKSPACE_LINKS_USAGE_UPDATES_STREAM_KEY, "*", {
      workspaceId,
      linksCount,
      timestamp,
    });
  } catch (error) {
    console.error(
      "Failed to publish workspace links usage event (falling back to database update):",
      error,
    );
    // fallback on writing directly to the database
    return await conn.execute(
      "UPDATE Project SET linksUsage = linksUsage + ?, totalLinks = totalLinks + ? WHERE id = ?",
      [linksCount, linksCount, workspaceId],
    );
  }

Sources: apps/web/lib/upstash/redis-streams/workspace-links-usage.ts:19-40

Workspace-specific click events for workspaces subscribed to webhooks are parsed against clickEventSchemaTB, verified against clickWebhookWorkspaces, and appended to workspace:click:events.

export const publishWorkspaceClickEvent = async (event) => {
  try {
    const parsedEvent = clickEventSchemaTB.parse({
      ...event,
      qr: Number(event.qr),
      bot: Number(event.bot),
    });
 
    const hasLinkClickedWebhooks = await clickWebhookWorkspaces.has(
      parsedEvent.workspace_id,
    );
 
    if (!hasLinkClickedWebhooks) {
      return;
    }
 
    return await redis.xadd(STREAM_KEY, "*", parsedEvent);
  } catch (error) {
    logger.error("stream.publish_failed", {
      service: "upstash",
      streamKey: STREAM_KEY,
      error: toErrorFields(error),
      correlation: {
        workspaceId: event.workspace_id,
        clickId: event.click_id,
        linkId: event.link_id,
      },
    });
 
    await logger.flush();
    throw error;
  }

Sources: apps/web/lib/upstash/redis-streams/workspace-click-events.ts:12-43

Stream Parameters and Aggregation Configuration

The click statistics cron worker defines control constants and thresholds governing batch consumption, backpressure alerts, and concurrency limits.

Constant NameValuePurpose
BATCH_SIZE10_000Max stream entries to consume per cron run
SUB_BATCH_SIZE50DB updates to run in parallel within each batch
BACKLOG_ALERT_THRESHOLD50_000Alert when stream length exceeds this count
BACKLOG_AGE_ALERT_MS300_000 (5 mins)Alert when oldest pending entry exceeds age limit
LOCK_KEY"lock:update-click-stats"Prevents concurrent GET/POST double-counting
LOCK_TTL_SECONDS600Lock TTL matching cron maxDuration

Sources: apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:15-20

Note

The aggregateClickStats function processes raw LinkClickEvent entries into maps for link updates, workspace usage increments, and program partner enrollment click tallies, resolving missing or invalid timestamps to Date.now().

Sources: apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:40-112

State Synchronization and Reconciliation

Scheduled Cron Synchronization Routines

Dub uses recurring cron routines to synchronize external states, clean up redundant metadata, and rebuild transient Redis resources from persistent databases. The synchronization endpoint executes every five minutes via withCron and coordinates multiple reconciliation routines across workspace integrations and webhook entries.

import { withCron } from "@/lib/cron/with-cron";
import { syncGoogleAdsInstalledWorkspaceSet } from "@/lib/integrations/google-ads/installed-workspaces";
import { logAndRespond } from "../utils";
import { cleanupRedundantLinkWebhookEntries } from "./cleanup-redundant-link-webhook-entries";
import { syncClickWebhookWorkspaceSet } from "./sync-click-webhook-workspace-set";
 
export const dynamic = "force-dynamic";
 
/*
    GET /api/cron/sync-redis-resources
    Rebuild various Redis resources:
    - syncClickWebhookWorkspaceSet: rebuild the Redis set of workspaces with active link.clicked webhooks
    - cleanupRedundantLinkWebhookEntries: remove redundant LinkWebhook entries for webhooks that are not scoped to links (folders, workspace)
    - syncGoogleAdsInstalledWorkspaceSet: rebuild the Redis set of workspaces with Google Ads installed
*/
 
// Runs every 5 minutes (*/5 * * * *)
export const GET = withCron(async () => {
  const result = await Promise.allSettled([
    syncClickWebhookWorkspaceSet(),
    cleanupRedundantLinkWebhookEntries(),
    syncGoogleAdsInstalledWorkspaceSet(),
  ]);
 
  [
    "syncClickWebhookWorkspaceSet",
    "cleanupRedundantLinkWebhookEntries",
    "syncGoogleAdsInstalledWorkspaceSet",
  ].map((name, index) => {
    const settled = result[index];
    if (settled.status === "fulfilled") {
      console.log(`${name}: ${settled.value}`);
    } else if (settled.status === "rejected") {
      console.error(`${name}: ${settled.reason}`);
    } else {
      console.error(`${name}: unknown error`);
    }
  });
 
  return logAndRespond("Synced Redis resources.");
});

Sources: apps/web/app/ee/api/cron/sync-redis-resources/route.ts:1-41

The synchronization pipeline delegates tasks using Promise.allSettled to guarantee that individual resource failures do not block other reconciliation routines. The executed routines and their integration behaviors are summarized below.

Routine FunctionSource FileReconciliation Target & Purpose
syncClickWebhookWorkspaceSetapps/web/app/ee/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.tsRebuilds the Redis set of workspaces maintaining active link click webhooks.
cleanupRedundantLinkWebhookEntriesapps/web/app/ee/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.tsRemoves redundant LinkWebhook database entries unlinked from specific links (e.g., scoped to folders or workspaces).
syncGoogleAdsInstalledWorkspaceSetapps/web/lib/integrations/google-ads/installed-workspaces.tsRebuilds the Redis set for workspaces with active Google Ads integrations.

Sources: apps/web/app/ee/api/cron/sync-redis-resources/route.ts:2-5, apps/web/lib/integrations/google-ads/installed-workspaces.ts:25-54

Note

syncGoogleAdsInstalledWorkspaceSet queries Prisma for installed integrations filtered by GOOGLE_ADS_INTEGRATION_ID and advanced or enterprise workspace plans. It writes updates into a temporary Redis key (googleAdsInstalledWorkspaces:tmp) before performing an atomic rename to replace the production googleAdsInstalledWorkspaces set, avoiding race conditions during reads.

Sources: apps/web/lib/integrations/google-ads/installed-workspaces.ts:25-54

Versioned Keys and Cache Invalidation

Cache invalidation for reward events and multi-tenant data dependencies relies on versioned Redis keys managed through incrementing version counters. The reward versioning utilities define keys prefixed by workspace and event type, maintaining a default time-to-live of 24 hours.

import { redis } from "@/lib/upstash";
import { EventType } from "@prisma/client";
 
const REWARD_VERSION_TTL_SECONDS = 24 * 60 * 60; // 24 hours
 
export function getRewardVersionKey({
  groupId,
  event,
}: {
  groupId: string;
  event: EventType; // Reward type
}) {
  return `reward-version:${groupId}:${event}`;
}
 
export async function incrementRewardVersion({
  groupId,
  event,
}: {
  groupId: string;
  event: EventType;
}) {
  const key = getRewardVersionKey({
    groupId,
    event,
  });
 
  const version = await redis.incr(key);
  await redis.expire(key, REWARD_VERSION_TTL_SECONDS);
  return version;
}
 
export async function isStaleRewardVersion({
  version,
  groupId,
  event,
}: {
  version: number;
  groupId: string;
  event: EventType;
}) {
  const key = getRewardVersionKey({
    groupId,
    event,
  });
 
  const currentVersion = await redis.get(key);
 
  return currentVersion != null && version < Number(currentVersion);
}

Sources: apps/web/lib/api/rewards/reward-version.ts:1-50

The reward versioning module exposes three core functions to handle cache invalidation checks and version bumps:

  • getRewardVersionKey({ groupId, event }): Constructs the Redis key string formatted as reward-version:${groupId}:${event}.
  • incrementRewardVersion({ groupId, event }): Atomically increments the version counter via redis.incr and resets its expiration to 24 hours (REWARD_VERSION_TTL_SECONDS).
  • isStaleRewardVersion({ version, groupId, event }): Compares an incoming version number against the current Redis version, returning true if a newer version exists.

Sources: apps/web/lib/api/rewards/reward-version.ts:4-50

Was this page helpful?