Fix duplicate listings via staged Redis cache and frontend stream cancellation

Three-pronged fix for duplicate listings appearing in the UI:

1. Backend: Replace direct rpush cache writes with staged population
   (write to temp key, then atomic RENAME to live key). Skip cache
   writes entirely for POI-enriched requests. Clean staging keys on
   invalidation.

2. Frontend: Add AbortController to cancel in-flight streaming requests
   when loadListings is called again, preventing data mixing.

3. Frontend: Deduplicate features by URL during stream accumulation as
   a safety net against any remaining server-side duplicates.
This commit is contained in:
Viktor Barzin 2026-02-09 21:17:30 +00:00
parent 5b8aa98446
commit 73d19e29d5
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 159 additions and 38 deletions

View file

@ -19,6 +19,7 @@ import { Filter } from 'lucide-react';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
import { useTaskWebSocket } from '@/hooks/useTaskWebSocket';
function App() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
@ -43,10 +44,15 @@ function App() {
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
// WebSocket-based real-time task progress
const { tasks: wsTasks, isConnected: wsConnected, subscribe: wsSubscribe } = useTaskWebSocket(user);
// Ref to track accumulated features during streaming
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
// Ref to track if initial load has been triggered
const initialLoadTriggeredRef = useRef(false);
// Ref to abort in-flight streaming requests
const abortControllerRef = useRef<AbortController | null>(null);
// Check if this is the callback route - render dedicated component
if (window.location.pathname === '/callback') {
@ -92,6 +98,13 @@ function App() {
const loadListings = useCallback(async (parameters: ParameterValues) => {
if (!user) return;
// Abort any in-flight streaming request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setQueryParameters(parameters);
setMobileFilterOpen(false);
setIsLoading(true);
@ -99,6 +112,9 @@ function App() {
setStreamingProgress({ count: 0 });
setListingData(null);
// Dedup safety net: track seen URLs to prevent duplicate features
const seenUrls = new Set<string>();
let updateScheduled = false;
const flushUpdate = () => {
@ -119,13 +135,26 @@ function App() {
try {
for await (const batch of streamListingGeoJSON(user, parameters, (progress) => {
setStreamingProgress(progress);
}, { includePoiDistances: userPOIs.length > 0 })) {
accumulatedFeaturesRef.current.push(...batch);
scheduleUpdate();
}, { includePoiDistances: userPOIs.length > 0, signal: controller.signal })) {
// Deduplicate features by URL
const uniqueBatch = batch.filter((feature) => {
const url = feature.properties?.url;
if (!url || seenUrls.has(url)) return false;
seenUrls.add(url);
return true;
});
if (uniqueBatch.length > 0) {
accumulatedFeaturesRef.current.push(...uniqueBatch);
scheduleUpdate();
}
}
// Final flush to ensure all data is rendered
flushUpdate();
} catch (error) {
// Silently ignore AbortError — it means we intentionally cancelled
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
if (error instanceof Error) {
setSubmitError(error.message);
} else {
@ -133,8 +162,11 @@ function App() {
}
setAlertDialogIsOpen(true);
} finally {
setIsLoading(false);
setStreamingProgress(null);
// Only clear loading state if this controller is still the current one
if (abortControllerRef.current === controller) {
setIsLoading(false);
setStreamingProgress(null);
}
}
}, [user, userPOIs]);
@ -217,6 +249,7 @@ function App() {
try {
const data = await refreshListings(user!, parameters);
setTaskID(data.task_id);
if (data.task_id) wsSubscribe(data.task_id);
} catch (error) {
if (error instanceof Error) {
setSubmitError(error.message);
@ -320,6 +353,7 @@ function App() {
const handlePOITaskCreated = (taskId: string) => {
setTaskID(taskId);
if (taskId) wsSubscribe(taskId);
// Refresh POI list in case new ones were created
if (user) {
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
@ -348,6 +382,9 @@ function App() {
taskID={taskID}
onTaskCancelled={handleTaskCancelled}
onTaskCompleted={handleTaskCompleted}
wsTasks={wsTasks}
wsConnected={wsConnected}
wsSubscribe={wsSubscribe}
/>
{/* Main content area */}

View file

@ -68,7 +68,7 @@ export async function* streamListingGeoJSON(
user: AuthUser,
parameters: ParameterValues,
onProgress?: (progress: StreamingProgress) => void,
options?: { includePoiDistances?: boolean },
options?: { includePoiDistances?: boolean; signal?: AbortSignal },
): AsyncGenerator<PropertyFeature[], void, unknown> {
const params = buildListingParams(parameters);
if (options?.includePoiDistances) {
@ -83,6 +83,7 @@ export async function* streamListingGeoJSON(
headers: {
Authorization: `Bearer ${user.accessToken}`,
},
signal: options?.signal,
});
if (!response.ok) {
@ -99,6 +100,10 @@ export async function* streamListingGeoJSON(
let totalCount = 0;
while (true) {
if (options?.signal?.aborted) {
await reader.cancel();
return;
}
const { done, value } = await reader.read();
if (done) break;