Merged in feature/redis (pull request #1478)

Distributed cache

* cache deleteKey now uses an options object instead of a lonely argument variable fuzzy

* merge

* remove debug logs and cleanup

* cleanup

* add fault handling

* add fault handling

* add pid when logging redis client creation

* add identifier when logging redis client creation

* cleanup

* feat: add redis-api as it's own app

* feature: use http wrapper for redis

* feat: add the possibility to fallback to unstable_cache

* Add error handling if redis cache is unresponsive

* add logging for unstable_cache

* merge

* don't cache errors

* fix: metadatabase on branchdeploys

* Handle when /en/destinations throws
add ErrorBoundary

* Add sentry-logging when ErrorBoundary catches exception

* Fix error handling for distributed cache

* cleanup code

* Added Application Insights back

* Update generateApiKeys script and remove duplicate

* Merge branch 'feature/redis' of bitbucket.org:scandic-swap/web into feature/redis

* merge


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-03-14 07:54:21 +00:00
committed by Linus Flood
parent a8304e543e
commit fa63b20ed0
141 changed files with 4404 additions and 1941 deletions

View File

@@ -269,6 +269,7 @@ export const countriesSchema = z.object({
}),
})
export type Cities = z.infer<typeof citiesSchema>
export const citiesSchema = z
.object({
data: z.array(citySchema),

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { z } from "zod"
import {
productTypePriceSchema,
productTypePointsSchema,
productTypePriceSchema,
} from "../productTypePrice"
export const productTypeSchema = z

View File

@@ -1,14 +1,16 @@
import deepmerge from "deepmerge"
import { unstable_cache } from "next/cache"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { toApiLang } from "@/server/utils"
import { getCacheClient } from "@/services/dataCache"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { metrics } from "./metrics"
import {
type Cities,
citiesByCountrySchema,
citiesSchema,
countriesSchema,
@@ -18,12 +20,10 @@ import {
import { getHotel } from "./query"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { HotelDataWithUrl } from "@/types/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
HotelLocation,
} from "@/types/trpc/routers/hotel/locations"
import type { Endpoint } from "@/lib/api/endpoints"
@@ -58,18 +58,21 @@ export function getPoiGroupByCategoryName(category: string | undefined) {
export const locationsAffix = "locations"
export const TWENTYFOUR_HOURS = 60 * 60 * 24
export async function getCity(
cityUrl: string,
options: RequestOptionsWithOutBody,
lang: Lang,
relationshipCity: HotelLocation["relationships"]["city"]
) {
return unstable_cache(
async function (locationCityUrl: string) {
const url = new URL(locationCityUrl)
export async function getCity({
cityUrl,
serviceToken,
}: {
cityUrl: string
serviceToken: string
}): Promise<Cities> {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
cityUrl,
async () => {
const url = new URL(cityUrl)
const cityResponse = await api.get(
url.pathname as Endpoint,
options,
{ headers: { Authorization: `Bearer ${serviceToken}` } },
url.searchParams
)
@@ -81,33 +84,44 @@ export async function getCity(
const city = citiesSchema.safeParse(cityJson)
if (!city.success) {
console.info(`Validation of city failed`)
console.info(`cityUrl: ${locationCityUrl}`)
console.info(`cityUrl: ${cityUrl}`)
console.error(city.error)
return null
}
return city.data
},
[cityUrl, `${lang}:${relationshipCity}`],
{ revalidate: TWENTYFOUR_HOURS }
)(cityUrl)
"1d"
)
}
export async function getCountries(
options: RequestOptionsWithOutBody,
params: URLSearchParams,
export async function getCountries({
lang,
serviceToken,
}: {
lang: Lang
) {
return unstable_cache(
async function (searchParams) {
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:${locationsAffix}:countries`,
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const countryResponse = await api.get(
api.endpoints.v1.Hotel.countries,
options,
searchParams
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
return null
throw new Error("Unable to fetch countries")
}
const countriesJson = await countryResponse.json()
@@ -120,114 +134,128 @@ export async function getCountries(
return countries.data
},
[`${lang}:${locationsAffix}:countries`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
"1d"
)
}
export async function getCitiesByCountry(
countries: string[],
options: RequestOptionsWithOutBody,
params: URLSearchParams,
lang: Lang,
onlyPublished = false, // false by default as it might be used in other places
affix: string = locationsAffix
) {
return unstable_cache(
async function (
searchParams: URLSearchParams,
searchedCountries: string[]
) {
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
await Promise.all(
searchedCountries.map(async (country) => {
export async function getCitiesByCountry({
countries,
lang,
onlyPublished = false,
affix = locationsAffix,
serviceToken,
}: {
countries: string[]
lang: Lang
onlyPublished?: boolean // false by default as it might be used in other places
affix?: string
serviceToken: string
}): Promise<CitiesGroupedByCountry> {
const cacheClient = await getCacheClient()
const allCitiesByCountries = await Promise.all(
countries.map(async (country) => {
return cacheClient.cacheOrGet(
`${lang}:${affix}:cities-by-country:${country}`,
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const countryResponse = await api.get(
api.endpoints.v1.Hotel.Cities.country(country),
options,
searchParams
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
return null
throw new Error(`Unable to fetch cities by country ${country}`)
}
const countryJson = await countryResponse.json()
const citiesByCountry = citiesByCountrySchema.safeParse(countryJson)
if (!citiesByCountry.success) {
console.info(`Failed to validate Cities by Country payload`)
console.error(`Unable to parse cities by country ${country}`)
console.error(citiesByCountry.error)
return null
throw new Error(`Unable to parse cities by country ${country}`)
}
const cities = onlyPublished
? citiesByCountry.data.data.filter((city) => city.isPublished)
: citiesByCountry.data.data
citiesGroupedByCountry[country] = cities
return true
})
return { ...citiesByCountry.data, country }
},
"1d"
)
})
)
return citiesGroupedByCountry
},
[
`${lang}:${affix}:cities-by-country`,
params.toString(),
JSON.stringify(countries),
],
{ revalidate: TWENTYFOUR_HOURS }
)(params, countries)
const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({
...country,
data: onlyPublished
? country.data.filter((city) => city.isPublished)
: country.data,
}))
const groupedCitiesByCountry: CitiesGroupedByCountry =
filteredCitiesByCountries.reduce((acc, { country, data }) => {
acc[country] = data
return acc
}, {} as CitiesGroupedByCountry)
return groupedCitiesByCountry
}
export async function getLocations(
lang: Lang,
options: RequestOptionsWithOutBody,
params: URLSearchParams,
export async function getLocations({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesGroupedByCountry | null
) {
return unstable_cache(
async function (
searchParams: URLSearchParams,
groupedCitiesByCountry: CitiesGroupedByCountry | null
) {
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:locations`.toLowerCase(),
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.locations,
options,
searchParams
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
throw new Error("unauthorized")
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
throw new Error("forbidden")
}
return null
throw new Error("downstream error")
}
const apiJson = await apiResponse.json()
const verifiedLocations = locationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
console.info(`Locations Verification Failed`)
console.error(verifiedLocations.error)
return null
throw new Error("Unable to parse locations")
}
return await Promise.all(
verifiedLocations.data.data.map(async (location) => {
if (location.type === "cities") {
if (groupedCitiesByCountry) {
const country = Object.keys(groupedCitiesByCountry).find(
(country) => {
if (
groupedCitiesByCountry[country].find(
(loc) => loc.name === location.name
)
) {
return true
}
return false
}
if (citiesByCountry) {
const country = Object.keys(citiesByCountry).find((country) =>
citiesByCountry[country].find(
(loc) => loc.name === location.name
)
)
if (country) {
return {
@@ -243,12 +271,10 @@ export async function getLocations(
}
} else if (location.type === "hotels") {
if (location.relationships.city?.url) {
const city = await getCity(
location.relationships.city.url,
options,
lang,
location.relationships.city
)
const city = await getCity({
cityUrl: location.relationships.city.url,
serviceToken,
})
if (city) {
return deepmerge(location, {
relationships: {
@@ -263,44 +289,51 @@ export async function getLocations(
})
)
},
[
`${lang}:${locationsAffix}`,
params.toString(),
JSON.stringify(citiesByCountry),
],
{ revalidate: TWENTYFOUR_HOURS }
)(params, citiesByCountry)
"1d"
)
}
export async function getHotelIdsByCityId(
cityId: string,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
metrics.hotelIds.counter.add(1, { params: params.toString() })
export async function getHotelIdsByCityId({
cityId,
serviceToken,
}: {
cityId: string
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${cityId}:hotelsByCityId`,
async () => {
const searchParams = new URLSearchParams({
city: cityId,
})
metrics.hotelIds.counter.add(1, { params: searchParams.toString() })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ params: params.toString() })
JSON.stringify({ params: searchParams.toString() })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
searchParams
)
if (!apiResponse.ok) {
const responseMessage = await apiResponse.text()
metrics.hotelIds.fail.add(1, {
params: params.toString(),
params: searchParams.toString(),
error_type: "http_error",
error: responseMessage,
})
console.error(
"api.hotel.hotel-ids fetch error",
JSON.stringify({
params: params.toString(),
params: searchParams.toString(),
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
@@ -309,59 +342,73 @@ export async function getHotelIdsByCityId(
})
)
return []
throw new Error("Unable to fetch hotelIds by cityId")
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metrics.hotelIds.fail.add(1, {
params: params.toString(),
params: searchParams.toString(),
error_type: "validation_error",
error: JSON.stringify(validatedHotelIds.error),
})
console.error(
"api.hotel.hotel-ids validation error",
JSON.stringify({
params: params.toString(),
params: searchParams.toString(),
error: validatedHotelIds.error,
})
)
return []
throw new Error("Unable to parse data for hotelIds by cityId")
}
metrics.hotelIds.success.add(1, { cityId })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({
params: params.toString(),
params: searchParams.toString(),
response: validatedHotelIds.data,
})
)
return validatedHotelIds.data
},
[`hotelsByCityId`, params.toString()],
{ revalidate: env.CACHE_TIME_HOTELS }
)(params)
env.CACHE_TIME_HOTELS
)
}
export async function getHotelIdsByCountry(
country: string,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
export async function getHotelIdsByCountry({
country,
serviceToken,
}: {
country: string
serviceToken: string
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${country}:hotelsByCountry`,
async () => {
metrics.hotelIds.counter.add(1, { country })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ query: { country } })
)
const hotelIdsParams = new URLSearchParams({
country,
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
hotelIdsParams
)
if (!apiResponse.ok) {
@@ -383,7 +430,7 @@ export async function getHotelIdsByCountry(
})
)
return []
throw new Error("Unable to fetch hotelIds by country")
}
const apiJson = await apiResponse.json()
@@ -401,7 +448,7 @@ export async function getHotelIdsByCountry(
error: validatedHotelIds.error,
})
)
return []
throw new Error("Unable to parse hotelIds by country")
}
metrics.hotelIds.success.add(1, { country })
@@ -412,62 +459,45 @@ export async function getHotelIdsByCountry(
return validatedHotelIds.data
},
[`hotelsByCountry`, params.toString()],
{ revalidate: env.CACHE_TIME_HOTELS }
)(params)
env.CACHE_TIME_HOTELS
)
}
export async function getHotelIdsByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const apiLang = toApiLang(Lang.en)
const city = await getCityByCityIdentifier(cityIdentifier, serviceToken)
const city = await getCityByCityIdentifier({
cityIdentifier,
lang: Lang.en,
serviceToken,
})
if (!city) {
return []
}
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: city.id,
const hotelIds = await getHotelIdsByCityId({
cityId: city.id,
serviceToken,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIds = await getHotelIdsByCityId(city.id, options, hotelIdsParams)
return hotelIds
}
export async function getCityByCityIdentifier(
cityIdentifier: string,
export async function getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
}: {
cityIdentifier: string
lang: Lang
serviceToken: string
) {
const lang = Lang.en
const apiLang = toApiLang(lang)
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const params = new URLSearchParams({
language: apiLang,
}) {
const locations = await getLocations({
lang,
citiesByCountry: null,
serviceToken,
})
const locations = await getLocations(lang, options, params, null)
if (!locations || "error" in locations) {
return null
}
@@ -479,11 +509,15 @@ export async function getCityByCityIdentifier(
return city ?? null
}
export async function getHotelsByHotelIds(
hotelIds: string[],
lang: Lang,
export async function getHotelsByHotelIds({
hotelIds,
lang,
serviceToken,
}: {
hotelIds: string[]
lang: Lang
serviceToken: string
) {
}) {
const hotelPages = await getHotelPageUrls(lang)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {