fix(scheduler): implement deadline-based matching with 5-run limit and fix security issues
Security fixes: - Replace $queryRawUnsafe with parameterized $queryRaw in admin.js to prevent SQL injection - Use PostgreSQL ANY() operator for safe array parameter handling Scheduler improvements: - Add registrationDeadline support - scheduler now waits until deadline before running - Implement 5-run limit after deadline (runs exactly 5 times with 5-minute intervals) - Add countScheduledRunsAfterDeadline() to track post-deadline runs - Add environment variable validation with sensible min/max ranges - Fix Prisma query syntax (remove invalid endDate null check for non-nullable field) UI improvements: - Fix colspan mismatch in MatchingRunsSection (6 → 8 columns) - Remove duplicate "Uruchom Matching" button, keep only "Run now" with audit tracking - Simplify MatchingConfigSection to focus on deadline configuration Logging enhancements: - Add detailed scheduler logs showing run progress (e.g., "Running post-deadline matching (3/5)") - Log wait times before deadline and between runs - Show completion status after 5 runs
This commit is contained in:
@@ -108,17 +108,16 @@ router.get('/events/:slug/matching-runs', authenticate, async (req, res, next) =
|
||||
// Cheap and valuable: shows actual created pairs in this run.
|
||||
if (runs.length > 0) {
|
||||
const runIds = runs.map(r => r.id);
|
||||
// Single SQL query for all listed runs
|
||||
const placeholders = runIds.join(',');
|
||||
const aggRows = await prisma.$queryRawUnsafe(
|
||||
`SELECT origin_run_id AS "originRunId",
|
||||
COUNT(*)::int AS "totalSuggestions",
|
||||
COUNT(*) FILTER (WHERE recorder_id IS NOT NULL)::int AS "assignedCount",
|
||||
COUNT(*) FILTER (WHERE status = 'not_found')::int AS "notFoundCount"
|
||||
FROM recording_suggestions
|
||||
WHERE event_id = ${event.id} AND origin_run_id IN (${placeholders})
|
||||
GROUP BY origin_run_id`
|
||||
);
|
||||
// Single SQL query for all listed runs (using parameterized query to prevent SQL injection)
|
||||
const aggRows = await prisma.$queryRaw`
|
||||
SELECT origin_run_id AS "originRunId",
|
||||
COUNT(*)::int AS "totalSuggestions",
|
||||
COUNT(*) FILTER (WHERE recorder_id IS NOT NULL)::int AS "assignedCount",
|
||||
COUNT(*) FILTER (WHERE status = 'not_found')::int AS "notFoundCount"
|
||||
FROM recording_suggestions
|
||||
WHERE event_id = ${event.id} AND origin_run_id = ANY(${runIds})
|
||||
GROUP BY origin_run_id
|
||||
`;
|
||||
const aggByRun = new Map(aggRows.map(r => [r.originRunId, r]));
|
||||
for (const r of runs) {
|
||||
const agg = aggByRun.get(r.id) || { totalSuggestions: 0, assignedCount: 0, notFoundCount: 0 };
|
||||
|
||||
@@ -6,8 +6,20 @@ const { SUGGESTION_STATUS } = require('../constants');
|
||||
// Designed for single-backend deployments. When scaling to multiple replicas,
|
||||
// add a DB-based lock (e.g., pg advisory lock) to ensure single run per event.
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = parseInt(process.env.SCHEDULER_INTERVAL_SEC || '300', 10); // 5 min
|
||||
const MIN_INTERVAL_SEC = parseInt(process.env.MATCHING_MIN_INTERVAL_SEC || '60', 10); // 1 min guard
|
||||
// Environment variable validation and parsing
|
||||
function parsePositiveInt(envVar, defaultValue, minValue = 1, maxValue = 86400) {
|
||||
const parsed = parseInt(envVar || String(defaultValue), 10);
|
||||
if (isNaN(parsed) || parsed < minValue || parsed > maxValue) {
|
||||
console.warn(
|
||||
`[scheduler] Invalid value for env var (parsed: ${parsed}). Using default: ${defaultValue}s`
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = parsePositiveInt(process.env.SCHEDULER_INTERVAL_SEC, 300, 30, 3600); // 5 min (range: 30s - 1h)
|
||||
const MIN_INTERVAL_SEC = parsePositiveInt(process.env.MATCHING_MIN_INTERVAL_SEC, 60, 10, 1800); // 1 min (range: 10s - 30min)
|
||||
|
||||
let timer = null;
|
||||
let runningEvents = new Set(); // In-memory guard to avoid overlapping runs per event
|
||||
@@ -20,15 +32,13 @@ async function listCandidateEvents() {
|
||||
return prisma.event.findMany({
|
||||
where: {
|
||||
// Include events that end today or in the future
|
||||
OR: [
|
||||
{ endDate: { gte: now } },
|
||||
{ endDate: { equals: null } },
|
||||
],
|
||||
endDate: { gte: now },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
matchingRunAt: true,
|
||||
registrationDeadline: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
@@ -36,13 +46,66 @@ async function listCandidateEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRunForEvent(event) {
|
||||
/**
|
||||
* Count how many times the scheduler has run matching for this event after the deadline
|
||||
* @param {Object} event - Event object with id and registrationDeadline
|
||||
* @returns {Promise<number>} - Number of scheduled runs after deadline
|
||||
*/
|
||||
async function countScheduledRunsAfterDeadline(event) {
|
||||
if (!event.registrationDeadline) {
|
||||
return 0; // No deadline set, no post-deadline runs
|
||||
}
|
||||
|
||||
const count = await prisma.matchingRun.count({
|
||||
where: {
|
||||
eventId: event.id,
|
||||
trigger: 'scheduler',
|
||||
startedAt: { gte: new Date(event.registrationDeadline) },
|
||||
},
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function shouldRunForEvent(event) {
|
||||
if (!event) return false;
|
||||
// Rate limiting per event by last run timestamp
|
||||
|
||||
const now = Date.now();
|
||||
const POST_DEADLINE_RUNS_LIMIT = 5; // Run matching 5 times after deadline
|
||||
const POST_DEADLINE_INTERVAL_SEC = 300; // 5 minutes between runs after deadline
|
||||
|
||||
// Check if registration deadline has passed (if set)
|
||||
if (event.registrationDeadline) {
|
||||
const deadline = new Date(event.registrationDeadline).getTime();
|
||||
|
||||
if (now < deadline) {
|
||||
return false; // Too early - deadline not reached yet
|
||||
}
|
||||
|
||||
// Deadline has passed - enforce 5-run limit with 5-minute intervals
|
||||
const runsAfterDeadline = await countScheduledRunsAfterDeadline(event);
|
||||
|
||||
if (runsAfterDeadline >= POST_DEADLINE_RUNS_LIMIT) {
|
||||
return false; // Already ran 5 times after deadline, stop scheduling
|
||||
}
|
||||
|
||||
// Check 5-minute interval between post-deadline runs
|
||||
if (event.matchingRunAt) {
|
||||
const last = new Date(event.matchingRunAt).getTime();
|
||||
const secondsSinceLastRun = (now - last) / 1000;
|
||||
|
||||
if (secondsSinceLastRun < POST_DEADLINE_INTERVAL_SEC) {
|
||||
return false; // Too soon, wait 5 minutes between runs
|
||||
}
|
||||
}
|
||||
|
||||
return true; // Deadline passed, under 5 runs, and 5 minutes elapsed - OK to run
|
||||
}
|
||||
|
||||
// No deadline set - use old rate limiting logic
|
||||
if (!event.matchingRunAt) return true;
|
||||
|
||||
const last = new Date(event.matchingRunAt).getTime();
|
||||
const now = Date.now();
|
||||
return (now - last) / 1000 >= MIN_INTERVAL_SEC;
|
||||
}
|
||||
|
||||
@@ -53,6 +116,21 @@ async function runForEvent(event) {
|
||||
|
||||
runningEvents.add(event.id);
|
||||
const startedAt = new Date();
|
||||
|
||||
// Log matching trigger reason
|
||||
if (event.registrationDeadline) {
|
||||
const deadlinePassed = new Date(event.registrationDeadline) <= startedAt;
|
||||
if (deadlinePassed) {
|
||||
// Count current run number (will be +1 after this run completes)
|
||||
const currentRuns = await countScheduledRunsAfterDeadline(event);
|
||||
console.log(
|
||||
`[scheduler] ${event.slug}: Running post-deadline matching (${currentRuns + 1}/5)...`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`[scheduler] ${event.slug}: Running scheduled matching (no deadline set)...`);
|
||||
}
|
||||
|
||||
let runRow = null;
|
||||
try {
|
||||
// Create run audit row
|
||||
@@ -110,12 +188,62 @@ async function runForEvent(event) {
|
||||
async function tick() {
|
||||
try {
|
||||
const events = await listCandidateEvents();
|
||||
if (events.length === 0) {
|
||||
return; // No events to process
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (shouldRunForEvent(event)) {
|
||||
const shouldRun = await shouldRunForEvent(event);
|
||||
|
||||
if (shouldRun) {
|
||||
// Fire and forget to allow parallel per-event processing in one process
|
||||
// but still guarded per event by runningEvents set
|
||||
// eslint-disable-next-line no-void
|
||||
void runForEvent(event);
|
||||
} else {
|
||||
// Log why event was skipped
|
||||
const now = Date.now();
|
||||
|
||||
if (event.registrationDeadline) {
|
||||
const deadline = new Date(event.registrationDeadline).getTime();
|
||||
|
||||
if (now < deadline) {
|
||||
// Before deadline
|
||||
const minutesUntil = Math.round((deadline - now) / 60000);
|
||||
console.log(
|
||||
`[scheduler] ${event.slug}: Waiting for deadline (in ${minutesUntil} min)`
|
||||
);
|
||||
} else {
|
||||
// After deadline - check why it's not running
|
||||
const runsAfterDeadline = await countScheduledRunsAfterDeadline(event);
|
||||
|
||||
if (runsAfterDeadline >= 5) {
|
||||
console.log(
|
||||
`[scheduler] ${event.slug}: Completed all 5 post-deadline runs (${runsAfterDeadline}/5)`
|
||||
);
|
||||
} else if (event.matchingRunAt) {
|
||||
const last = new Date(event.matchingRunAt).getTime();
|
||||
const secondsSince = Math.round((now - last) / 1000);
|
||||
const secondsUntil = 300 - secondsSince; // 5 minutes = 300 seconds
|
||||
|
||||
if (secondsUntil > 0) {
|
||||
console.log(
|
||||
`[scheduler] ${event.slug}: Post-deadline run ${runsAfterDeadline}/5 - next in ${Math.round(secondsUntil / 60)} min`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.matchingRunAt) {
|
||||
// No deadline set - show rate limiting info
|
||||
const last = new Date(event.matchingRunAt).getTime();
|
||||
const secondsSince = Math.round((now - last) / 1000);
|
||||
if (secondsSince < MIN_INTERVAL_SEC) {
|
||||
const secondsUntil = MIN_INTERVAL_SEC - secondsSince;
|
||||
console.log(
|
||||
`[scheduler] ${event.slug}: Rate limited (retry in ${secondsUntil}s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { matchingAPI } from '../../services/api';
|
||||
|
||||
/**
|
||||
* Auto-matching configuration section
|
||||
* Allows setting registration deadline and running matching algorithm
|
||||
* Allows setting registration deadline
|
||||
*/
|
||||
const MatchingConfigSection = ({ slug, event, onRefresh }) => {
|
||||
const [deadlineInput, setDeadlineInput] = useState(() => {
|
||||
@@ -15,7 +15,6 @@ const MatchingConfigSection = ({ slug, event, onRefresh }) => {
|
||||
return '';
|
||||
});
|
||||
const [savingDeadline, setSavingDeadline] = useState(false);
|
||||
const [runningMatching, setRunningMatching] = useState(false);
|
||||
|
||||
const handleSaveDeadline = async () => {
|
||||
try {
|
||||
@@ -31,20 +30,6 @@ const MatchingConfigSection = ({ slug, event, onRefresh }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunMatching = async () => {
|
||||
try {
|
||||
setRunningMatching(true);
|
||||
const result = await matchingAPI.runMatching(slug);
|
||||
alert(`Matching zakonczony! Dopasowano: ${result.matched}, Nie znaleziono: ${result.notFound}`);
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to run matching:', err);
|
||||
alert('Nie udalo sie uruchomic matchingu');
|
||||
} finally {
|
||||
setRunningMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
@@ -52,79 +37,45 @@ const MatchingConfigSection = ({ slug, event, onRefresh }) => {
|
||||
Auto-Matching (Nagrywanie)
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Registration Deadline */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Clock size={16} className="inline mr-1" />
|
||||
Deadline rejestracji
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Matching uruchomi sie 30 min po tym terminie
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={deadlineInput}
|
||||
onChange={(e) => setDeadlineInput(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveDeadline}
|
||||
disabled={savingDeadline}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{savingDeadline ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
Zapisz
|
||||
</button>
|
||||
</div>
|
||||
{event?.registrationDeadline && (
|
||||
<p className="text-sm text-green-600 mt-2">
|
||||
Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matching Status & Run */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Status matchingu
|
||||
</label>
|
||||
{event?.matchingRunAt ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-3">
|
||||
<p className="text-green-800 text-sm">
|
||||
Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-3">
|
||||
<p className="text-amber-800 text-sm">
|
||||
Matching nie byl jeszcze uruchomiony
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Registration Deadline */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Clock size={16} className="inline mr-1" />
|
||||
Deadline rejestracji
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Scheduler automatycznie uruchomi matching po tym terminie
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={deadlineInput}
|
||||
onChange={(e) => setDeadlineInput(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRunMatching}
|
||||
disabled={runningMatching}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
onClick={handleSaveDeadline}
|
||||
disabled={savingDeadline}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{runningMatching ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
Trwa matching...
|
||||
</>
|
||||
{savingDeadline ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Video size={16} />
|
||||
Uruchom Matching
|
||||
</>
|
||||
<Save size={16} />
|
||||
)}
|
||||
Zapisz
|
||||
</button>
|
||||
</div>
|
||||
{event?.registrationDeadline && (
|
||||
<p className="text-sm text-green-600 mt-2">
|
||||
Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')}
|
||||
</p>
|
||||
)}
|
||||
{event?.matchingRunAt && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -109,11 +109,11 @@ export default function MatchingRunsSection({ slug }) {
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-3 py-3 text-center text-gray-500">Loading...</td>
|
||||
<td colSpan="8" className="px-3 py-3 text-center text-gray-500">Loading...</td>
|
||||
</tr>
|
||||
) : runs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-3 py-3 text-center text-gray-500">No runs yet</td>
|
||||
<td colSpan="8" className="px-3 py-3 text-center text-gray-500">No runs yet</td>
|
||||
</tr>
|
||||
) : (
|
||||
runs.map((run) => (
|
||||
|
||||
Reference in New Issue
Block a user