Merged in feature/warmup (pull request #1887)

* unified warmup function

Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-04-29 06:18:14 +00:00
parent bbbd665a32
commit c1505ce50e
33 changed files with 886 additions and 185 deletions

View File

@@ -0,0 +1,139 @@
import { type NextRequest, NextResponse } from "next/server"
import { env } from "@/env/server"
import { warmup } from "@/services/warmup"
import { isWarmupKey } from "@/services/warmup/warmupKeys"
import { createLogger } from "@/utils/logger"
export const dynamic = "force-dynamic"
const logger = createLogger("Warmup")
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const key = url.searchParams.get("key")
if (!isAuthroized(req)) {
return unauthorizedResponse()
}
const executionStart = performance.now()
if (!isWarmupKey(key)) {
return invalidKeyResponse(key)
}
logger.debug("Warming up:", key)
const warmupResult = await warmup(key)
const executionTime = performance.now() - executionStart
switch (warmupResult.status) {
case "completed":
return warmupCompletedResponse(warmupResult, key, executionTime)
case "error":
return warmupErrorResponse(warmupResult, key, executionTime)
case "skipped":
return warmupSkippedResponse(warmupResult, key, executionTime)
default:
const status = (warmupResult as unknown as { status: string }).status
throw new Error(`Unknown warmup status '${status}'`)
}
}
function isAuthroized(req: NextRequest) {
const authHeader = req.headers.get("Authorization")
if (!authHeader || !env.WARMUP_TOKEN) {
return false
}
const token = authHeader.split(" ")[1]
if (!token) {
return false
}
return token === env.WARMUP_TOKEN
}
function warmupCompletedResponse(
warmupResult: Awaited<ReturnType<typeof warmup>>,
key: string,
executionTime: number
) {
logger.debug(`Warmup completed: ${key} in ${executionTime.toFixed(2)}ms`)
return NextResponse.json(
{
...warmupResult,
key,
executionTime: `${executionTime.toFixed(2)}ms`,
},
{ status: 200 }
)
}
function warmupSkippedResponse(
warmupResult: Awaited<ReturnType<typeof warmup>>,
key: string,
executionTime: number
) {
logger.debug("Warmup skipped:", key)
return NextResponse.json(
{
key,
status: warmupResult.status,
executionTime: `${executionTime.toFixed(2)}ms`,
},
{ status: 200 }
)
}
function warmupErrorResponse(
warmupResult: Awaited<ReturnType<typeof warmup>>,
key: string,
executionTime: number
) {
if (warmupResult.status !== "error") {
throw new Error("Warmup result is not an error")
}
logger.error("Warmup error", {
key,
error: warmupResult.error.message,
})
return NextResponse.json(
{
key,
status: warmupResult.status,
error: warmupResult.error.message,
executionTime: `${executionTime.toFixed(2)}ms`,
},
{ status: 500 }
)
}
function unauthorizedResponse() {
return NextResponse.json(
{
error: "Unauthorized access",
},
{ status: 401 }
)
}
function invalidKeyResponse(key: string | null) {
if (isWarmupKey(key)) {
throw new Error("Invalid invocation with valid cache key")
}
logger.error(`Invalid key ${key}`)
return NextResponse.json(
{
key,
error: `Invalid warmup key: '${key}'`,
},
{ status: 400 }
)
}

View File

@@ -210,17 +210,17 @@ export default function SummaryUI({
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{showMemberPrice
? formatPrice(
intl,
memberPrice.amount,
memberPrice.currency
)
intl,
memberPrice.amount,
memberPrice.currency
)
: formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</Body>
</div>
<Caption color="uiTextMediumContrast">
@@ -272,22 +272,22 @@ export default function SummaryUI({
</div>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}>
<div>
<div className={styles.entry} key={feature.code}>
<div>
<Body color="uiTextHighContrast">
{feature.description}
</Body>
</div>
<Body color="uiTextHighContrast">
{feature.description}
{formatPrice(
intl,
feature.localPrice.price,
feature.localPrice.currency
)}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
feature.localPrice.price,
feature.localPrice.currency
)}
</Body>
</div>
))
))
: null}
{room.bedType ? (
<div className={styles.entry}>

View File

@@ -144,8 +144,8 @@ export default function ConfirmationStep({
label={
savedCreditCards?.length
? intl.formatMessage({
defaultMessage: "OTHER",
})
defaultMessage: "OTHER",
})
: undefined
}
>

View File

@@ -2,14 +2,13 @@
import { Button as ButtonRAC } from "react-aria-components"
import {
MaterialIcon
,type
MaterialIconProps} from "@scandic-hotels/design-system/Icons/MaterialIcon"
MaterialIcon,
type MaterialIconProps,
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./button.module.css"
interface ButtonProps extends React.PropsWithChildren {
icon: MaterialIconProps["icon"]
isDisabled?: boolean

View File

@@ -9,14 +9,13 @@ import {
clearGlaSessionStorage,
readGlaFromSessionStorage,
} from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback/helpers"
import LoadingSpinner from "@/components/LoadingSpinner"
import { trackEvent } from "@/utils/tracking/base"
import { buildAncillaries } from "@/utils/tracking/myStay"
import {
buildAncillaryPackages,
getAncillarySessionData,
} from "@/components/HotelReservation/MyStay/utils/ancillaries"
import LoadingSpinner from "@/components/LoadingSpinner"
import { trackEvent } from "@/utils/tracking/base"
import { buildAncillaries } from "@/utils/tracking/myStay"
interface TrackGuaranteeProps {
status: string
@@ -93,21 +92,21 @@ export default function TrackGuarantee({
case PaymentCallbackStatusEnum.Cancel:
isAncillaryFlow
? trackAncillaryPaymentEvent(
"GuaranteeCancelAncillary",
"glacardsavecancelled"
)
"GuaranteeCancelAncillary",
"glacardsavecancelled"
)
: trackGuaranteePaymentEvent(
"glaCardSaveCancelled",
"glacardsavecancelled"
)
"glaCardSaveCancelled",
"glacardsavecancelled"
)
break
case PaymentCallbackStatusEnum.Error:
isAncillaryFlow
? trackAncillaryPaymentEvent(
"GuaranteeFailAncillary",
"glacardsavefailed"
)
"GuaranteeFailAncillary",
"glacardsavefailed"
)
: trackGuaranteePaymentEvent("glaCardSaveFailed", "glacardsavefailed")
break
}

View File

@@ -1,4 +1,4 @@
import { type TbodyProps,tbodyVariants } from "./variants"
import { type TbodyProps, tbodyVariants } from "./variants"
export default function Tbody({ border, children }: TbodyProps) {
const classNames = tbodyVariants({ border })

View File

@@ -29,12 +29,8 @@ export default function Campaign({
roomTypeCode,
}: CampaignProps) {
const intl = useIntl()
const {
roomNr,
selectedFilter,
selectedPackages,
selectedRate,
} = useRoomContext()
const { roomNr, selectedFilter, selectedPackages, selectedRate } =
useRoomContext()
const rateTitles = useRateTitles()
const isCampaignRate = campaign.some(
@@ -78,21 +74,21 @@ export default function Campaign({
const rateTermDetails = product.rateDefinitionMember
? [
{
title: product.rateDefinition.title,
terms: product.rateDefinition.generalTerms,
},
{
title: product.rateDefinitionMember.title,
terms: product.rateDefinition.generalTerms,
},
]
{
title: product.rateDefinition.title,
terms: product.rateDefinition.generalTerms,
},
{
title: product.rateDefinitionMember.title,
terms: product.rateDefinition.generalTerms,
},
]
: [
{
title: product.rateDefinition.title,
terms: product.rateDefinition.generalTerms,
},
]
{
title: product.rateDefinition.title,
terms: product.rateDefinition.generalTerms,
},
]
const isSelected = isSelectedPriceProduct(
product,
@@ -127,12 +123,12 @@ export default function Campaign({
const pricePerNightMember = product.member
? calculatePricePerNightPriceProduct(
product.member.localPrice.pricePerNight,
product.member.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
product.member.localPrice.pricePerNight,
product.member.requestedPrice?.pricePerNight,
nights,
pkgsSum.price,
pkgsSumRequested.price
)
: undefined
let approximateRatePrice = undefined
@@ -148,12 +144,12 @@ export default function Campaign({
const approximateRate =
approximateRatePrice && product.public.requestedPrice
? {
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price: approximateRatePrice,
unit: product.public.requestedPrice.currency,
}
label: intl.formatMessage({
defaultMessage: "Approx.",
}),
price: approximateRatePrice,
unit: product.public.requestedPrice.currency,
}
: undefined
return (
@@ -167,12 +163,12 @@ export default function Campaign({
memberRate={
pricePerNightMember
? {
label: intl.formatMessage({
defaultMessage: "Member price",
}),
price: pricePerNightMember.totalPrice,
unit: `${product.member!.localPrice.currency}/${night}`,
}
label: intl.formatMessage({
defaultMessage: "Member price",
}),
price: pricePerNightMember.totalPrice,
unit: `${product.member!.localPrice.currency}/${night}`,
}
: undefined
}
name={`rateCode-${roomNr}-${product.public.rateCode}`}
@@ -186,15 +182,15 @@ export default function Campaign({
omnibusRate={
product.public.localPrice.omnibusPricePerNight
? {
label: intl
.formatMessage({
defaultMessage: "Lowest price (last 30 days)",
})
.toUpperCase(),
price:
product.public.localPrice.omnibusPricePerNight.toString(),
unit: product.public.localPrice.currency,
}
label: intl
.formatMessage({
defaultMessage: "Lowest price (last 30 days)",
})
.toUpperCase(),
price:
product.public.localPrice.omnibusPricePerNight.toString(),
unit: product.public.localPrice.currency,
}
: undefined
}
rateTermDetails={rateTermDetails}

View File

@@ -79,9 +79,9 @@ export default function Redemptions({
additionalPrice:
additionalPrice && additionalPriceCurrency
? {
currency: additionalPriceCurrency,
price: additionalPrice.toString(),
}
currency: additionalPriceCurrency,
price: additionalPrice.toString(),
}
: undefined,
currency: "PTS",
isDisabled: !r.redemption.hasEnoughPoints,

View File

@@ -199,6 +199,7 @@ export const env = createEnv({
// transform to boolean
.transform((s) => s === "true")
.default("true"),
WARMUP_TOKEN: z.string().optional(),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -293,6 +294,7 @@ export const env = createEnv({
BRANCH: process.env.BRANCH,
GIT_SHA: process.env.GIT_SHA,
ENABLE_WARMUP_HOTEL: process.env.ENABLE_WARMUP_HOTEL,
WARMUP_TOKEN: process.env.WARMUP_TOKEN,
},
})

View File

@@ -1,12 +0,0 @@
import { Lang } from "@/constants/languages"
import type { Config, Context } from "@netlify/functions"
import { warmupHotelDataOnLang } from "../utils/hoteldata"
export default async (_request: Request, _context: Context) => {
await warmupHotelDataOnLang(Lang.da)
return new Response("Warmup success", { status: 200 })
}
export const config: Config = {
schedule: "0 4 * * *",
}

View File

@@ -1,12 +0,0 @@
import { Lang } from "@/constants/languages"
import type { Config, Context } from "@netlify/functions"
import { warmupHotelDataOnLang } from "../utils/hoteldata"
export default async (_request: Request, _context: Context) => {
await warmupHotelDataOnLang(Lang.de)
return new Response("Warmup success", { status: 200 })
}
export const config: Config = {
schedule: "5 4 * * *",
}

View File

@@ -1,12 +0,0 @@
import { Lang } from "@/constants/languages"
import type { Config, Context } from "@netlify/functions"
import { warmupHotelDataOnLang } from "../utils/hoteldata"
export default async (_request: Request, _context: Context) => {
await warmupHotelDataOnLang(Lang.en)
return new Response("Warmup success", { status: 200 })
}
export const config: Config = {
schedule: "10 4 * * *",
}

View File

@@ -1,12 +0,0 @@
import { Lang } from "@/constants/languages"
import type { Config, Context } from "@netlify/functions"
import { warmupHotelDataOnLang } from "../utils/hoteldata"
export default async (_request: Request, _context: Context) => {
await warmupHotelDataOnLang(Lang.fi)
return new Response("Warmup success", { status: 200 })
}
export const config: Config = {
schedule: "15 4 * * *",
}

View File

@@ -1,12 +0,0 @@
import { Lang } from "@/constants/languages"
import type { Config, Context } from "@netlify/functions"
import { warmupHotelDataOnLang } from "../utils/hoteldata"
export default async (_request: Request, _context: Context) => {
await warmupHotelDataOnLang(Lang.no)
return new Response("Warmup success", { status: 200 })
}
export const config: Config = {
schedule: "20 4 * * *",
}

View File

@@ -1,12 +0,0 @@
import { Lang } from "@/constants/languages"
import type { Config, Context } from "@netlify/functions"
import { warmupHotelDataOnLang } from "../utils/hoteldata"
export default async (_request: Request, _context: Context) => {
await warmupHotelDataOnLang(Lang.sv)
return new Response("Warmup success", { status: 200 })
}
export const config: Config = {
schedule: "25 4 * * *",
}

View File

@@ -0,0 +1,249 @@
import crypto from "crypto"
import jwt from "jsonwebtoken"
import {
type WarmupFunctionsKey,
warmupKeys,
} from "@/services/warmup/warmupKeys"
import { safeTry } from "@/utils/safeTry"
import { timeout } from "@/utils/timeout"
import type { Config, Context } from "@netlify/functions"
async function WarmupHandler(request: Request, context: Context) {
const [_, error] = await safeTry(validateRequest(request, context))
if (error) {
if (error instanceof Error) {
switch (error.message as ErrorCode) {
case ErrorCodes.WARMUP_DISABLED:
console.warn("[WARMUP] Warmup is disabled")
return
case ErrorCodes.URLS_DONT_MATCH:
// This is expected, this webhook will be called for all deployments
// and we only want to warmup the ones that match our URL
return
}
}
console.error("[WARMUP] Warmup failed", error)
return
}
console.log("[WARMUP] Request is valid, starting warmup")
await performWarmup(context)
console.log("[WARMUP] Warmup completed")
}
async function validateRequest(
request: Request,
context: Context
): Promise<true> {
if (request.method !== "POST") {
throw new Error(ErrorCodes.METHOD_NOT_ALLOWED)
}
const warmupEnabled =
true ||
process.env.WARMUP_ENABLED === "true" ||
Netlify.env.get("WARMUP_ENABLED") === "true"
if (!warmupEnabled) {
throw new Error(ErrorCodes.WARMUP_DISABLED)
}
if (!request.body) {
throw new Error(ErrorCodes.MISSING_BODY)
}
const body = await request.text()
console.log("[WARMUP] Warmup body", body)
const deployment = JSON.parse(body) as DeploymentInfo
if (!deployment) {
throw new Error(ErrorCodes.UNABLE_TO_PARSE_DEPLOYMENT_INFO)
}
const deployedUrl = deployment.deploy_ssl_url
if (deployedUrl !== context.url.origin) {
throw new Error(ErrorCodes.URLS_DONT_MATCH)
}
console.log("[WARMUP] Warmup request", deployment)
let signature: string
try {
const headerValue = request.headers.get("x-webhook-signature")
if (!headerValue) {
throw new Error(ErrorCodes.MISSING_SIGNATURE_HEADER)
}
signature = headerValue
} catch (e) {
console.warn("[WARMUP] Failed to parse signature", e)
throw new Error(ErrorCodes.FAILED_TO_PARSE_SIGNATURE)
}
if (!signature) {
throw new Error(ErrorCodes.MISSING_SIGNATURE)
}
const isValid = await validateSignature(signature, body)
if (!isValid) {
throw new Error(ErrorCodes.INVALID_SIGNATURE)
}
return true
}
export async function performWarmup(context: Context) {
for (const key of warmupKeys) {
console.log("[WARMUP] Warming up cache", key)
await callWarmup(key, context)
// allow api to catch up
await timeout(1000)
}
}
async function callWarmup(key: WarmupFunctionsKey, context: Context) {
const baseUrl = context.url.origin
const url = new URL("/api/web/warmup", baseUrl)
url.searchParams.set("key", key)
const warmupToken =
process.env.WARMUP_TOKEN || Netlify.env.get("WARMUP_TOKEN")
try {
const response = await fetch(url, {
headers: {
cache: "no-store",
Authorization: `Bearer ${warmupToken}`,
},
})
if (!response.ok) {
console.error(
`[WARMUP] Warmup failed '${url.href}' with error: ${response.status}: ${response.statusText}`
)
}
} catch (error) {
console.error(
`[WARMUP] Warmup failed '${url.href}' with error: ${error instanceof Error ? error.message : error}`
)
}
}
export default WarmupHandler
export const config: Config = {
method: "POST",
}
async function validateSignature(token: string, buffer: string) {
try {
const secret =
process.env.WARMUP_SIGNATURE_SECRET ||
Netlify.env.get("WARMUP_SIGNATURE_SECRET")
if (!secret) {
throw new Error("WARMUP_SIGNATURE_SECRET is not set")
}
const options: jwt.VerifyOptions = {
issuer: "netlify",
algorithms: ["HS256"],
}
const decoded = jwt.verify(token, secret, options)
const hashedBody = crypto.createHash("sha256").update(buffer).digest("hex")
if (!hasSha256(decoded)) {
console.error(
"[WARMUP] Decoded jwt does not contain sha256, unable to verify signature"
)
return false
}
return decoded.sha256 === hashedBody
} catch (error) {
console.error("[WARMUP] Failed to validate signature", error)
return false
}
}
function hasSha256(decoded: unknown): decoded is { sha256: string } {
return (
typeof decoded === "object" &&
decoded !== null &&
"sha256" in decoded &&
typeof (decoded as { sha256: unknown }).sha256 === "string"
)
}
type DeploymentInfo = {
id: string
site_id: string
build_id: string
state: string
name: string
url: string
ssl_url: string
admin_url: string
deploy_url: string
deploy_ssl_url: string
created_at: string
updated_at: string
user_id: string
error_message: string | null
required: any[]
required_functions: any[]
commit_ref: string
review_id: string | null
branch: string
commit_url: string
skipped: any
locked: any
title: string
commit_message: string | null
review_url: string | null
published_at: string | null
context: string
deploy_time: number
available_functions: unknown[]
screenshot_url: string | null
committer: string
skipped_log: any
manual_deploy: boolean
plugin_state: string
lighthouse_plugin_scores: any
links: {
permalink: string
alias: string
branch: string | null
}
framework: string
entry_path: string | null
views_count: number | null
function_schedules: {
cron: string
name: string
}[]
public_repo: boolean
pending_review_reason: string | null
lighthouse: any
edge_functions_present: boolean
expires_at: string | null
blobs_region: string
}
const ErrorCodes = {
DEPLOYMENT_NOT_FOUND: "DEPLOYMENT NOT FOUND",
FAILED_TO_PARSE_SIGNATURE: "FAILED TO PARSE SIGNATURE",
INVALID_DEPLOYMENT_STATE: "INVALID DEPLOYMENT STATE",
INVALID_SIGNATURE: "INVALID SIGNATURE",
METHOD_NOT_ALLOWED: "METHOD NOT ALLOWED",
MISSING_BODY: "MISSING BODY",
MISSING_SIGNATURE_HEADER: "MISSING SIGNATURE HEADER",
MISSING_SIGNATURE: "MISSING SIGNATURE",
UNABLE_TO_PARSE_DEPLOYMENT_INFO: "UNABLE TO PARSE DEPLOYMENT INFO",
URLS_DONT_MATCH: "URLS DONT MATCH",
WARMUP_DISABLED: "WARMUP IS DISABLED",
} as const
type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]

View File

@@ -0,0 +1,14 @@
import { performWarmup } from "./warmup-background.mjs"
import type { Config, Context } from "@netlify/functions"
async function WarmupHandler(_request: Request, context: Context) {
console.log("[WARMUP] Starting scheduled warmup")
await performWarmup(context)
console.log("[WARMUP] Scheduled warmup completed")
}
export default WarmupHandler
export const config: Config = {
schedule: "0 4 * * *",
}

View File

@@ -88,6 +88,7 @@
"input-otp": "^1.4.2",
"ioredis": "^5.5.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.10.60",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
@@ -125,6 +126,7 @@
"@types/adm-zip": "^0.5.7",
"@types/jest": "^29.5.12",
"@types/json-stable-stringify-without-jsonify": "^1.0.2",
"@types/jsonwebtoken": "^9",
"@types/lodash-es": "^4",
"@types/node": "^20",
"@types/react": "^18",

View File

@@ -666,6 +666,7 @@ export const hotelQueryRouter = router({
lang: lang,
serviceToken: ctx.serviceToken,
})
return hotels
})
)
@@ -674,15 +675,14 @@ export const hotelQueryRouter = router({
return hotelData
}
if (warmup) {
return await fetchHotels()
}
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:getDestinationsMapData`,
fetchHotels,
"max"
"max",
{
cacheStrategy: warmup ? "fetch-then-cache" : "cache-first",
}
)
}),
}),

View File

@@ -130,9 +130,11 @@ export async function getCity({
export async function getCountries({
lang,
serviceToken,
warmup = false,
}: {
lang: Lang
serviceToken: string
warmup?: boolean
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
@@ -166,7 +168,10 @@ export async function getCountries({
return countries.data
},
"1d"
"1d",
{
cacheStrategy: warmup ? "fetch-then-cache" : "cache-first",
}
)
}

View File

@@ -1,3 +1,5 @@
import type { CacheOrGetOptions } from "./cacheOrGetOptions"
const ONE_HOUR_IN_SECONDS = 3_600 as const
const ONE_DAY_IN_SECONDS = 86_400 as const
@@ -60,6 +62,7 @@ export type DataCache = {
* @param key The cache key
* @param getDataFromSource An async function that provides a value to cache
* @param ttl Time to live, either a named cache time or a number of seconds
* @param opts Options to control cache behavior when retrieving or storing data.
* @returns The cached value or the result from the callback
*/
cacheOrGet: <T>(
@@ -67,7 +70,8 @@ export type DataCache = {
getDataFromSource: (
overrideTTL?: (cacheTime: CacheTime) => void
) => Promise<T>,
ttl: CacheTime
ttl: CacheTime,
opts?: CacheOrGetOptions
) => Promise<T>
/**

View File

@@ -1,5 +1,9 @@
import { type CacheTime, type DataCache } from "@/services/dataCache/Cache"
import {
type CacheOrGetOptions,
shouldGetFromCache,
} from "../cacheOrGetOptions"
import { cacheLogger } from "../logger"
import { generateCacheKey } from "./generateCacheKey"
import { get } from "./get"
@@ -8,10 +12,15 @@ import { set } from "./set"
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
key: string | string[],
callback: (overrideTTL: (cacheTime: CacheTime) => void) => Promise<T>,
ttl: CacheTime
ttl: CacheTime,
opts?: CacheOrGetOptions
) => {
const cacheKey = generateCacheKey(key)
const cachedValue = await get<T>(cacheKey)
let cachedValue: Awaited<T> | undefined = undefined
if (shouldGetFromCache(opts)) {
cachedValue = await get<T>(cacheKey)
}
let realTTL = ttl

View File

@@ -1,13 +1,18 @@
import { type CacheTime, type DataCache } from "@/services/dataCache/Cache"
import { cacheLogger } from "@/services/dataCache/logger"
import {
type CacheOrGetOptions,
shouldGetFromCache,
} from "../../cacheOrGetOptions"
import { get } from "./get"
import { set } from "./set"
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
key: string | string[],
callback: (overrideTTL?: (cacheTime: CacheTime) => void) => Promise<T>,
ttl: CacheTime
ttl: CacheTime,
opts?: CacheOrGetOptions
): Promise<T> => {
if (Array.isArray(key)) {
key = key.join("-")
@@ -18,14 +23,16 @@ export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
realTTL = cacheTime
}
const cached = await get(key)
let cached: Awaited<T> | undefined = undefined
if (shouldGetFromCache(opts)) {
cached = await get(key)
if (cached) {
return cached
}
if (cached) {
return cached as T
cacheLogger.debug(`Miss for key '${key}'`)
}
cacheLogger.debug(`Miss for key '${key}'`)
try {
const data = await callback(overrideTTL)
await set(key, data, realTTL)

View File

@@ -1,4 +1,4 @@
import { unstable_cache } from "next/cache"
import { revalidateTag, unstable_cache } from "next/cache"
import {
type CacheTime,
@@ -6,18 +6,26 @@ import {
getCacheTimeInSeconds,
} from "@/services/dataCache/Cache"
import {
type CacheOrGetOptions,
shouldGetFromCache,
} from "../../cacheOrGetOptions"
import { cacheLogger } from "../../logger"
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
key: string | string[],
callback: () => Promise<T>,
ttl: CacheTime
ttl: CacheTime,
opts?: CacheOrGetOptions
): Promise<T> => {
if (!Array.isArray(key)) {
key = [key]
}
const perf = performance.now()
if (!shouldGetFromCache(opts)) {
revalidateTag(key[0])
}
const res = await unstable_cache(callback, key, {
revalidate: getCacheTimeInSeconds(ttl),

View File

@@ -0,0 +1,27 @@
/**
* Options to control cache behavior when retrieving or storing data.
*
* - "cache-first": Default behaviour, check if the needed data is available in the cache first. If the data is found, it is returned immediately. Otherwise, the data is fetched and then cached.
* - "fetch-then-cache": Always fetch the data first, and then update the cache with the freshly fetched data.
*/
export type CacheStrategy = "cache-first" | "fetch-then-cache"
export type CacheOrGetOptions = {
cacheStrategy?: CacheStrategy
}
export function defaultCacheOrGetOptions(
opts: CacheOrGetOptions = {}
): CacheOrGetOptions {
return {
cacheStrategy: "cache-first",
...opts,
}
}
export function shouldGetFromCache(
opts: CacheOrGetOptions | undefined
): boolean {
opts = defaultCacheOrGetOptions(opts)
return opts.cacheStrategy === "cache-first"
}

View File

@@ -3,11 +3,10 @@ import { env } from "@/env/server"
import { isEdge } from "@/utils/isEdge"
import { createMemoryCache } from "./MemoryCache/createMemoryCache"
import { type DataCache } from "./Cache"
import { createDistributedCache } from "./DistributedCache"
import { cacheLogger } from "./logger"
import type { DataCache } from "./Cache"
export type { CacheTime, DataCache } from "./Cache"
export async function getCacheClient(): Promise<DataCache> {

View File

@@ -0,0 +1,50 @@
import { Lang } from "@/constants/languages"
import { warmupCountry } from "./warmupCountries"
import { warmupHotelData } from "./warmupHotelData"
import { warmupHotelIdsByCountry } from "./warmupHotelIdsByCountry"
import type { WarmupFunctionsKey } from "./warmupKeys"
export type WarmupFunction = () => Promise<WarmupResult>
type BaseWarmup = {
status: "skipped" | "completed"
}
type FailedWarmup = {
status: "error"
error: Error
}
export type WarmupResult = BaseWarmup | FailedWarmup
export const warmupFunctions: Record<WarmupFunctionsKey, WarmupFunction> = {
countries_en: warmupCountry(Lang.en),
countries_da: warmupCountry(Lang.da),
countries_de: warmupCountry(Lang.de),
countries_fi: warmupCountry(Lang.fi),
countries_sv: warmupCountry(Lang.sv),
countries_no: warmupCountry(Lang.no),
hotelsByCountry: warmupHotelIdsByCountry(),
hotelData_en: warmupHotelData(Lang.en),
hotelData_da: warmupHotelData(Lang.da),
hotelData_de: warmupHotelData(Lang.de),
hotelData_fi: warmupHotelData(Lang.fi),
hotelData_sv: warmupHotelData(Lang.sv),
hotelData_no: warmupHotelData(Lang.no),
}
export async function warmup(key: WarmupFunctionsKey): Promise<WarmupResult> {
const func = warmupFunctions[key]
if (!func) {
return {
status: "error",
error: new Error(`Warmup function ${key} not found`),
}
}
return func()
}

View File

@@ -0,0 +1,28 @@
import { getCountries } from "@/server/routers/hotels/utils"
import { getServiceToken } from "@/server/tokenManager"
import type { Lang } from "@/constants/languages"
import type { WarmupFunction, WarmupResult } from "."
export const warmupCountry =
(lang: Lang): WarmupFunction =>
async (): Promise<WarmupResult> => {
try {
const serviceToken = await getServiceToken()
await getCountries({
lang: lang,
serviceToken: serviceToken.access_token,
warmup: true,
})
} catch (error) {
return {
status: "error",
error: error as Error,
}
}
return {
status: "completed",
}
}

View File

@@ -0,0 +1,31 @@
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import type { Lang } from "@/constants/languages"
import type { WarmupFunction, WarmupResult } from "."
export const warmupHotelData =
(lang: Lang): WarmupFunction =>
async (): Promise<WarmupResult> => {
if (!env.ENABLE_WARMUP_HOTEL) {
return {
status: "skipped",
}
}
try {
await serverClient().hotel.hotels.getDestinationsMapData({
lang,
warmup: true,
})
} catch (error) {
return {
status: "error",
error: error as Error,
}
}
return {
status: "completed",
}
}

View File

@@ -0,0 +1,60 @@
import { Lang } from "@/constants/languages"
import {
getCountries,
getHotelIdsByCountry,
} from "@/server/routers/hotels/utils"
import { getServiceToken } from "@/server/tokenManager"
import { safeTry } from "@/utils/safeTry"
import type { WarmupFunction, WarmupResult } from "."
export const warmupHotelIdsByCountry =
(): WarmupFunction => async (): Promise<WarmupResult> => {
try {
let serviceToken = await getServiceToken()
const [countries, countriesError] = await safeTry(
getCountries({
lang: Lang.en,
serviceToken: serviceToken.access_token,
warmup: true,
})
)
if (!countries || countriesError) {
return {
status: "error",
error: new Error("Unable to get countries"),
}
}
const countryNames = countries.data.map((country) => country.name)
for (const countryName of countryNames) {
serviceToken = await getServiceToken()
const [_, error] = await safeTry(
getHotelIdsByCountry({
country: countryName,
serviceToken: serviceToken.access_token,
})
)
if (error) {
console.error(
`[Warmup]: Error fetching hotel IDs for ${countryName}:`,
error
)
continue
}
}
return {
status: "completed",
}
} catch (error) {
return {
status: "error",
error: error as Error,
}
}
}

View File

@@ -0,0 +1,20 @@
import { Lang } from "@/constants/languages"
const langs = Object.keys(Lang) as Lang[]
/*
* Keys for warmup functions, the order of the keys is the order in which they will be executed
*/
export const warmupKeys = [
...langs.map((lang) => `countries_${lang}` as const),
"hotelsByCountry",
...langs.map((lang) => `hotelData_${lang}` as const),
] as const
export type WarmupFunctionsKey = (typeof warmupKeys)[number]
export function isWarmupKey(key: unknown): key is WarmupFunctionsKey {
return (
typeof key === "string" && warmupKeys.includes(key as WarmupFunctionsKey)
)
}

View File

@@ -0,0 +1,16 @@
export function createLogger(loggerPrefix: string | (() => Promise<string>)) {
const getLoggerPrefix: () => Promise<string> =
typeof loggerPrefix === "string" ? async () => loggerPrefix : loggerPrefix
return {
async debug(message: string, ...args: unknown[]): Promise<void> {
console.debug(`[${await getLoggerPrefix()}] ${message}`, ...args)
},
async warn(message: string, ...args: unknown[]): Promise<void> {
console.warn(`[${await getLoggerPrefix()}] Warning - ${message}`, ...args)
},
async error(message: string, ...args: unknown[]): Promise<void> {
console.error(`[${await getLoggerPrefix()}] Error - ${message}`, ...args)
},
}
}