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:
parent
5b8aa98446
commit
73d19e29d5
5 changed files with 159 additions and 38 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue