Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,30 @@ jobs:
- name: Install dependencies
run: npm ci

# The service healthcheck above already gates job start, but keep an
# explicit probe using a deterministic container lookup (published port,
# then service name) instead of matching on the runner's image ancestry,
# which could select the wrong container after a runner image change.
- name: Wait for Redis
run: |
set -euo pipefail
for i in $(seq 1 30); do
if docker exec $(docker ps -q --filter "ancestor=redis:7-alpine") redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Redis is ready!"
container_id="$(docker ps -q --filter 'publish=6379' | head -n1)"
if [ -z "$container_id" ]; then
container_id="$(docker ps -q --filter 'name=redis' | head -n1)"
fi
if [ -n "$container_id" ] && docker exec "$container_id" redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Redis is ready (container ${container_id})."
exit 0
fi
echo "Waiting for Redis... ($i/30)"
echo "Waiting for Redis... (${i}/30, container=${container_id:-none})"
sleep 2
done
echo "Redis failed to start"
echo "::error::Redis did not respond to PING within 60s."
echo "--- docker ps -a ---"
docker ps -a
echo "--- container logs ---"
docker logs "${container_id:-$(docker ps -aq | head -n1)}" 2>&1 | tail -n 50 || true
exit 1

# json-summary is what scripts/write-coverage-summary.mjs renders into the
Expand Down
28 changes: 28 additions & 0 deletions docs/cache-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,31 @@ This document summarizes the public API surface of the cache manager.
- `exportCacheData()`: Exports the cache data to a JSON string.
- `importCacheData(jsonData)`: Imports cache data from a JSON string.
- `createCachedFetch(fetcher, key, strategy, ttl)`: Creates a cached fetch wrapper that supports different caching strategies.

## Redis property cache invalidation (#1100)

The server-side property cache (`src/lib/redisCache.ts`) keeps a reverse index
per property: writing a detail, listing or search entry records its key under
`propchain:index:property:<id>`.

- `invalidateProperty(id)` reads that index and deletes only the keys that
actually embed the property (its detail key plus any listing/search keys it
appears in). The index set is then removed. When the index is missing it
falls back to the previous narrow pattern scan.
- `invalidateAllProperties()` is now reserved for explicit full flushes (bulk
imports, cache resets) and also clears the index sets.
- Blockchain events only flush `listing:*`/`search:*` for membership-changing
events (`PropertyCreated`, `PropertyDelisted`); updates stay on the narrow
index path.

### Hit-rate notes (before/after)

- **Before:** a single property update evicted every `property:*`, `listing:*`
and `search:*` key, forcing a thundering-herd refetch of unrelated searches
and detail pages. Measured effect on a warm cache: listing/search hit rate
collapses toward 0% immediately after any mutation, then recovers as entries
are rebuilt.
- **After:** only keys associated with the mutated property are evicted, so
unrelated searches and detail pages keep serving from cache. Expected effect:
listing/search hit rate now stays stable across mutations (only the mutated
property's own entries miss and regenerate).
21 changes: 17 additions & 4 deletions src/app/api/properties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,23 @@ export const POST = withCsrf(async function (request: NextRequest) {
// Here you would normally save the property to your database/blockchain
// For now, we'll just invalidate the cache

// Invalidate relevant cache entries
await redisCacheService.invalidateAllProperties();

logger.info('Property cache invalidated due to property creation/update');
// Invalidate narrowly when we know which property changed; only fall back
// to a listing/search flush for creations whose id isn't provided, since a
// new entry can change which properties a listing matches.
const propertyId =
propertyData && typeof propertyData === 'object' && 'id' in propertyData &&
typeof (propertyData as { id?: unknown }).id === 'string'
? (propertyData as { id: string }).id
: null;

if (propertyId) {
await redisCacheService.invalidateProperty(propertyId);
logger.info(`Property ${propertyId} cache invalidated due to creation/update`);
} else {
await redisCacheService.invalidatePattern('listing:*');
await redisCacheService.invalidatePattern('search:*');
logger.info('Listing/search cache invalidated due to property creation');
}

return NextResponse.json({
message: 'Property created/updated successfully',
Expand Down
11 changes: 6 additions & 5 deletions src/app/properties/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PropertyDetailClient } from '@/components/PropertyDetailClient';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { getPropertyForISR } from '@/lib/propertyServiceServer';
import { getPropertyForISR, getPopularPropertyIds } from '@/lib/propertyServiceServer';
import type { Property } from '@/types/property';

// ISR configuration - revalidate every 60 seconds
Expand Down Expand Up @@ -120,9 +120,10 @@ function PropertyDetailSkeleton() {
);
}

// Generate static params for known properties
// Pre-render the popular property detail pages at build time. Any id that is
// not returned here is still generated on demand (dynamicParams defaults to
// true) and revalidated by ISR, so new properties work without a rebuild.
export async function generateStaticParams() {
// In a real implementation, you would fetch this from your API/database
// For now, we'll return an empty array to generate pages on-demand
return [];
const ids = await getPopularPropertyIds();
return ids.map((id) => ({ id }));
}
23 changes: 23 additions & 0 deletions src/lib/__tests__/propertyServiceServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
jest.mock('next/cache', () => ({ revalidatePath: jest.fn() }));

import { getPopularPropertyIds } from '../propertyServiceServer';
import { MOCK_PROPERTIES, getFeaturedProperties } from '../mockData';

describe('getPopularPropertyIds (#1102)', () => {
it('returns real, pre-renderable ids for generateStaticParams', async () => {
const ids = await getPopularPropertyIds();
const featured = getFeaturedProperties();

expect(featured.length).toBeGreaterThan(0);
expect(ids).toEqual(featured.map((property) => property.id));
expect(ids.length).toBeGreaterThan(0);
expect(ids.every((id) => MOCK_PROPERTIES.some((property) => property.id === id))).toBe(
true
);
});

it('respects the limit', async () => {
const ids = await getPopularPropertyIds(2);
expect(ids).toHaveLength(2);
});
});
119 changes: 111 additions & 8 deletions src/lib/__tests__/redisCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { redisCacheService } from '../redisCache';
import { redisCacheService, CACHE_KEYS } from '../redisCache';
import { MOCK_PROPERTIES } from '../mockData';
import type { SearchFilters } from '@/types/property';

const store = new Map<string, string>();
const sets = new Map<string, Set<string>>();

const fakeClient = {
get: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)),
setex: jest.fn((key: string, _ttl: number, value: string) => {
Expand All @@ -12,24 +16,71 @@ const fakeClient = {
store.set(key, next);
return Promise.resolve(Number(next));
}),
del: jest.fn(() => Promise.resolve(1)),
keys: jest.fn(() => Promise.resolve([])),
del: jest.fn((...keys: string[]) => {
keys.forEach((key) => {
store.delete(key);
sets.delete(key);
});
return Promise.resolve(keys.length);
}),
// The fake client has no key prefix, so normalize the physical pattern
// (`propchain:*`) that invalidatePattern builds back to logical names.
keys: jest.fn((pattern: string) => {
const prefix = pattern.replace(/^propchain:/, '').replace('*', '');
return Promise.resolve([...store.keys()].filter((key) => key.startsWith(prefix)));
}),
sadd: jest.fn((key: string, ...members: string[]) => {
if (!sets.has(key)) sets.set(key, new Set());
members.forEach((member) => sets.get(key)!.add(member));
return Promise.resolve(members.length);
}),
srem: jest.fn((key: string, ...members: string[]) => {
members.forEach((member) => sets.get(key)?.delete(member));
return Promise.resolve(members.length);
}),
smembers: jest.fn((key: string) => Promise.resolve([...(sets.get(key) ?? [])])),
expire: jest.fn(() => Promise.resolve(1)),
ping: jest.fn(() => Promise.resolve('PONG')),
};

jest.mock('../redis', () => ({ getRedisClient: jest.fn(() => fakeClient) }));
jest.mock('../redis', () => ({
getRedisClient: jest.fn(() => fakeClient),
REDIS_KEY_PREFIX: 'propchain:',
}));

const FILTERS: SearchFilters = {
query: '',
priceRange: [0, 10000000],
propertyTypes: [],
blockchains: [],
roiMin: 0,
roiMax: 100,
location: '',
bedrooms: [],
bathrooms: [],
squareFeetRange: [0, 50000],
status: ['active'],
};

const otherFilters: SearchFilters = { ...FILTERS, location: 'Miami' };

describe('redisCacheService', () => {
beforeEach(() => {
store.clear();
sets.clear();
jest.clearAllMocks();
});

it('records a cache miss then a hit for the same property', async () => {
await redisCacheService.getProperty('p1');
const property = MOCK_PROPERTIES[0];

await redisCacheService.getProperty('missing');
expect(fakeClient.incr).toHaveBeenCalledWith('cache:hit_rate:misses');

await redisCacheService.setProperty({ id: 'p1' } as any);
await redisCacheService.getProperty('p1');
await redisCacheService.setProperty(property);
const cached = await redisCacheService.getProperty(property.id);

expect(cached).toEqual(property);
expect(fakeClient.incr).toHaveBeenCalledWith('cache:hit_rate');
});

Expand All @@ -39,8 +90,60 @@ describe('redisCacheService', () => {
expect(result).toBeNull();
});

it('discards a poisoned payload that parses but fails schema validation', async () => {
store.set('property:poisoned', JSON.stringify({ id: 'poisoned', evil: '<script>' }));

const result = await redisCacheService.getProperty('poisoned');

expect(result).toBeNull();
expect(fakeClient.incr).toHaveBeenCalledWith('cache:hit_rate:invalid');
expect(fakeClient.incr).toHaveBeenCalledWith('cache:hit_rate:misses');
});

it('discards a search result payload with a malformed property entry', async () => {
const key = CACHE_KEYS.PROPERTY_LISTING(FILTERS, 'newest', 1);
store.set(
key,
JSON.stringify({ properties: [{ id: 'x' }], total: 1, page: 1, totalPages: 1 })
);

const result = await redisCacheService.getPropertyListings(FILTERS, 'newest', 1);

expect(result).toBeNull();
expect(fakeClient.incr).toHaveBeenCalledWith('cache:hit_rate:invalid');
});

it('returns stats shape from getStats', async () => {
const stats = await redisCacheService.getStats();
expect(stats).toMatchObject({ hits: 0, misses: 0, total: 0, hitRate: 0 });
expect(stats).toMatchObject({ hits: 0, misses: 0, total: 0, hitRate: 0, invalid: 0 });
});

it('invalidates only the keys related to an updated property', async () => {
const [first, second] = MOCK_PROPERTIES;
const relatedListingKey = CACHE_KEYS.PROPERTY_LISTING(FILTERS, 'newest', 1);
const unrelatedListingKey = CACHE_KEYS.PROPERTY_LISTING(otherFilters, 'newest', 1);

await redisCacheService.setProperty(first);
await redisCacheService.setProperty(second);
await redisCacheService.setPropertyListings(FILTERS, 'newest', 1, {
properties: [first],
total: 1,
page: 1,
totalPages: 1,
});
await redisCacheService.setPropertyListings(otherFilters, 'newest', 1, {
properties: [second],
total: 1,
page: 1,
totalPages: 1,
});

await redisCacheService.invalidateProperty(first.id);

// Related entries are gone...
expect(store.has(relatedListingKey)).toBe(false);
// ...while unrelated cache entries survive the update.
expect(store.has(unrelatedListingKey)).toBe(true);
expect(await redisCacheService.getProperty(second.id)).toEqual(second);
});
});
16 changes: 6 additions & 10 deletions src/lib/blockchainCacheInvalidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,16 +265,12 @@ class BlockchainCacheInvalidator {
return;
}

// For events that affect listings, also invalidate listing cache
if (
[
"PropertyCreated",
"PropertyUpdated",
"PropertySold",
"PropertyListed",
"PropertyDelisted",
].includes(event.type)
) {
// `invalidateProperty` (above) already evicts the listing/search entries
// that embed this property via the key index, so updates no longer flush
// the whole cache. Only membership-changing events can affect listings that
// don't contain this property (ordering, result counts), so those keep a
// broader — but still explicit — listing/search invalidation.
if (["PropertyCreated", "PropertyDelisted"].includes(event.type)) {
await redisCacheService.invalidatePattern("listing:*");
await redisCacheService.invalidatePattern("search:*");
logger.info(`Invalidated listing and search cache due to ${event.type}`);
Expand Down
15 changes: 15 additions & 0 deletions src/lib/propertyServiceServer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { logger } from '@/utils/logger';
import { propertyService } from './propertyService';
import { MOCK_PROPERTIES, getFeaturedProperties } from './mockData';
import type { Property } from '@/types/property';
import { revalidatePath } from 'next/cache';

/**
* Server-side property service functions for ISR
*/

/**
* IDs to pre-render at build time via `generateStaticParams`.
*
* Featured properties are the shared/SEO entry points that benefit most from
* being served statically, so they come first; if none are flagged we fall
* back to the full catalogue. Any id not returned here is still generated on
* demand and kept fresh by ISR (`revalidate = 60`).
*/
export async function getPopularPropertyIds(limit: number = 20): Promise<string[]> {
const featured = getFeaturedProperties();
const source = featured.length > 0 ? featured : MOCK_PROPERTIES;
return source.slice(0, limit).map((property) => property.id);
}

/**
* Get property data for ISR - server side only
*/
Expand Down
6 changes: 5 additions & 1 deletion src/lib/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const DEFAULT_REDIS_CONFIG: RedisConfig = {
commandTimeout: 5000,
};

// Physical key prefix applied by the ioredis client below. Exported so callers
// that build raw patterns (which ioredis does not prefix) can stay consistent.
export const REDIS_KEY_PREFIX = 'propchain:';

// Redis client instance
let redisClient: Redis | null = null;

Expand Down Expand Up @@ -75,7 +79,7 @@ export const initRedis = async (): Promise<Redis> => {
connectTimeout: config.connectTimeout,
commandTimeout: config.commandTimeout,
// Enable key prefixing for property cache
keyPrefix: 'propchain:',
keyPrefix: REDIS_KEY_PREFIX,
// Enable compression for large values
enableAutoPipelining: true,
// Connection events
Expand Down
Loading
Loading