diff --git a/PLAN.md b/PLAN.md index 5d3fd05..2c65e6b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -250,7 +250,7 @@ DELETE /api/jobs/{id}/save - Unsave job ## Phase 8: Performance Optimization ### 8.1 Caching Strategy -- Cache job listings (Redis/Memcached) +- Cache job listings (Redis) - Cache search results (5-15 minutes) - Cache categories and locations - Cache statistics/counters diff --git a/app/Console/Commands/OptimizeDatabaseCommand.php b/app/Console/Commands/OptimizeDatabaseCommand.php new file mode 100644 index 0000000..2070af0 --- /dev/null +++ b/app/Console/Commands/OptimizeDatabaseCommand.php @@ -0,0 +1,66 @@ +info('Starting database optimization...'); + + $tables = [ + 'jobs', + 'job_categories', + 'job_sources', + 'job_locations', + 'api_logs', + 'job_fetch_logs', + 'analytics_events', + ]; + + foreach ($tables as $table) { + $this->info("Optimizing table: {$table}"); + + try { + // Optimize table (defragment and rebuild indexes) + $this->optimizationService->optimizeTable($table); + $this->line(" ✓ Optimized"); + + // Analyze table statistics + $this->optimizationService->analyzeTable($table); + $this->line(" ✓ Analyzed"); + + // Get table stats + $stats = $this->optimizationService->getTableStats($table); + if ($stats) { + $this->line(" Size: {$stats['size_mb']} MB (Data: {$stats['data_size_mb']} MB, Indexes: {$stats['index_size_mb']} MB)"); + $this->line(" Rows: " . number_format($stats['table_rows'])); + } + + $this->newLine(); + } catch (\Exception $e) { + $this->error(" ✗ Failed: " . $e->getMessage()); + } + } + + $this->info('Database optimization completed!'); + + return self::SUCCESS; + } +} diff --git a/app/Repositories/JobRepository.php b/app/Repositories/JobRepository.php index 9164ba2..98f6497 100644 --- a/app/Repositories/JobRepository.php +++ b/app/Repositories/JobRepository.php @@ -3,11 +3,15 @@ namespace App\Repositories; use App\Models\Job; +use App\Services\CacheService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; class JobRepository { + public function __construct( + private CacheService $cacheService + ) {} /** * Get all active jobs with pagination */ @@ -124,24 +128,28 @@ class JobRepository */ public function getFeaturedJobs(int $limit = 10): Collection { - // First, build a subquery with window function - $subQuery = \DB::table('jobs') - ->selectRaw('jobs.*, ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn') - ->whereNull('deleted_at') - ->where(function ($query) { - $query->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }); + $cacheKey = $this->cacheService->featuredJobsKey($limit); - // Then query from that subquery filtering by rn - // Use withoutGlobalScopes to prevent SoftDeletes from adding WHERE clause - return Job::withoutGlobalScopes() - ->with(['source', 'category', 'jobLocation']) - ->fromSub($subQuery, 'ranked_jobs') - ->whereRaw('ranked_jobs.rn <= 2') - ->orderBy('posted_at', 'desc') - ->limit($limit) - ->get(); + return $this->cacheService->rememberWithTags(['jobs'], $cacheKey, CacheService::CACHE_FEATURED_JOBS, function () use ($limit) { + // First, build a subquery with window function + $subQuery = \DB::table('jobs') + ->selectRaw('jobs.*, ROW_NUMBER() OVER (PARTITION BY job_source_id ORDER BY posted_at DESC) as rn') + ->whereNull('deleted_at') + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + + // Then query from that subquery filtering by rn + // Use withoutGlobalScopes to prevent SoftDeletes from adding WHERE clause + return Job::withoutGlobalScopes() + ->with(['source', 'category', 'jobLocation']) + ->fromSub($subQuery, 'ranked_jobs') + ->whereRaw('ranked_jobs.rn <= 2') + ->orderBy('posted_at', 'desc') + ->limit($limit) + ->get(); + }); } /** @@ -149,27 +157,31 @@ class JobRepository */ public function getStatistics(): array { - $total = Job::count(); - $active = Job::active()->count(); - $remote = Job::active()->remote()->count(); - $recentlyAdded = Job::where('created_at', '>=', now()->subDays(7))->count(); + $cacheKey = $this->cacheService->statisticsKey(); - $topCategories = Job::active() - ->select('job_category_id', \DB::raw('COUNT(*) as count')) - ->whereNotNull('job_category_id') - ->groupBy('job_category_id') - ->orderBy('count', 'desc') - ->limit(5) - ->with('category') - ->get(); + return $this->cacheService->remember($cacheKey, CacheService::CACHE_STATISTICS, function () { + $total = Job::count(); + $active = Job::active()->count(); + $remote = Job::active()->remote()->count(); + $recentlyAdded = Job::where('created_at', '>=', now()->subDays(7))->count(); - return [ - 'total_jobs' => $total, - 'active_jobs' => $active, - 'remote_jobs' => $remote, - 'recently_added' => $recentlyAdded, - 'top_categories' => $topCategories, - ]; + $topCategories = Job::active() + ->select('job_category_id', \DB::raw('COUNT(*) as count')) + ->whereNotNull('job_category_id') + ->groupBy('job_category_id') + ->orderBy('count', 'desc') + ->limit(5) + ->with('category') + ->get(); + + return [ + 'total_jobs' => $total, + 'active_jobs' => $active, + 'remote_jobs' => $remote, + 'recently_added' => $recentlyAdded, + 'top_categories' => $topCategories, + ]; + }); } /** diff --git a/app/Services/CacheService.php b/app/Services/CacheService.php new file mode 100644 index 0000000..d3a257d --- /dev/null +++ b/app/Services/CacheService.php @@ -0,0 +1,178 @@ +flush(); + } + + /** + * Clear category caches + */ + public function clearCategoryCaches(): void + { + Cache::tags(['categories'])->flush(); + } + + /** + * Clear statistics caches + */ + public function clearStatisticsCaches(): void + { + Cache::forget($this->statisticsKey()); + } + + /** + * Clear all application caches + */ + public function clearAllCaches(): void + { + Cache::flush(); + } + + /** + * Remember with tags (for better cache management) + */ + public function rememberWithTags(array $tags, string $key, int $ttl, callable $callback) + { + return Cache::tags($tags)->remember($key, $ttl, $callback); + } + + /** + * Get cached value or store new one + */ + public function remember(string $key, int $ttl, callable $callback) + { + return Cache::remember($key, $ttl, $callback); + } + + /** + * Get from cache + */ + public function get(string $key, $default = null) + { + return Cache::get($key, $default); + } + + /** + * Put value in cache + */ + public function put(string $key, $value, int $ttl): bool + { + return Cache::put($key, $value, $ttl); + } + + /** + * Forget cache key + */ + public function forget(string $key): bool + { + return Cache::forget($key); + } + + /** + * Increment counter (useful for statistics) + */ + public function increment(string $key, int $value = 1): int + { + return Cache::increment($key, $value); + } + + /** + * Decrement counter + */ + public function decrement(string $key, int $value = 1): int + { + return Cache::decrement($key, $value); + } +} diff --git a/app/Services/JobSearchService.php b/app/Services/JobSearchService.php index 2d48818..af4adb6 100644 --- a/app/Services/JobSearchService.php +++ b/app/Services/JobSearchService.php @@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Builder; class JobSearchService { + public function __construct( + private CacheService $cacheService + ) {} /** * Search jobs with advanced filters */ @@ -188,36 +191,40 @@ class JobSearchService */ public function getFilterOptions(): array { - return [ - 'job_types' => [ - 'full-time' => 'Full-time', - 'part-time' => 'Part-time', - 'contract' => 'Contract', - 'temporary' => 'Temporary', - 'internship' => 'Internship', - 'freelance' => 'Freelance', - ], - 'experience_levels' => [ - 'entry' => 'Entry Level', - 'mid' => 'Mid Level', - 'senior' => 'Senior Level', - 'lead' => 'Lead', - 'executive' => 'Executive', - ], - 'posted_within' => [ - 1 => 'Last 24 hours', - 7 => 'Last 7 days', - 30 => 'Last 30 days', - 90 => 'Last 3 months', - ], - 'sort_options' => [ - 'date' => 'Most Recent', - 'date_asc' => 'Oldest First', - 'salary_desc' => 'Highest Salary', - 'salary_asc' => 'Lowest Salary', - 'title' => 'Title A-Z', - 'company' => 'Company A-Z', - ], - ]; + $cacheKey = $this->cacheService->filterOptionsKey(); + + return $this->cacheService->remember($cacheKey, CacheService::CACHE_FILTER_OPTIONS, function () { + // Get actual distinct values from database for job types and experience levels + $jobTypes = Job::select('job_type') + ->whereNotNull('job_type') + ->distinct() + ->pluck('job_type') + ->toArray(); + + $experienceLevels = Job::select('experience_level') + ->whereNotNull('experience_level') + ->distinct() + ->pluck('experience_level') + ->toArray(); + + return [ + 'job_types' => $jobTypes, + 'experience_levels' => $experienceLevels, + 'posted_within' => [ + 1 => 'Last 24 hours', + 7 => 'Last 7 days', + 30 => 'Last 30 days', + 90 => 'Last 3 months', + ], + 'sort_options' => [ + 'date' => 'Most Recent', + 'date_asc' => 'Oldest First', + 'salary_desc' => 'Highest Salary', + 'salary_asc' => 'Lowest Salary', + 'title' => 'Title A-Z', + 'company' => 'Company A-Z', + ], + ]; + }); } } diff --git a/app/Services/QueryOptimizationService.php b/app/Services/QueryOptimizationService.php new file mode 100644 index 0000000..1a2e69e --- /dev/null +++ b/app/Services/QueryOptimizationService.php @@ -0,0 +1,195 @@ +with([ + 'source:id,name', + 'category:id,name,slug,icon', + 'jobLocation:id,city,state,country', + ]); + } + + /** + * Optimize job listing query + */ + public function optimizeJobListingQuery(Builder $query): Builder + { + return $query + ->select([ + 'id', + 'title', + 'company', + 'location', + 'job_type', + 'experience_level', + 'salary_min', + 'salary_max', + 'salary_currency', + 'remote_allowed', + 'posted_at', + 'job_source_id', + 'job_category_id', + 'job_location_id', + 'created_at', + ]) + ->with([ + 'source:id,name', + 'category:id,name,slug,icon', + 'jobLocation:id,city,state,country', + ]); + } + + /** + * Analyze slow queries + */ + public function analyzeSlowQueries(int $threshold = 1000): array + { + // Enable query logging + DB::enableQueryLog(); + + // Get queries that took longer than threshold (in milliseconds) + $queries = DB::getQueryLog(); + + $slowQueries = array_filter($queries, function ($query) use ($threshold) { + return ($query['time'] ?? 0) > $threshold; + }); + + return array_map(function ($query) { + return [ + 'query' => $query['query'], + 'bindings' => $query['bindings'], + 'time' => $query['time'] . 'ms', + ]; + }, $slowQueries); + } + + /** + * Get query explain plan + */ + public function explainQuery(string $sql, array $bindings = []): array + { + $explained = DB::select('EXPLAIN ' . $sql, $bindings); + + return array_map(function ($row) { + return (array) $row; + }, $explained); + } + + /** + * Optimize table (defragment and rebuild indexes) + */ + public function optimizeTable(string $table): void + { + DB::statement("OPTIMIZE TABLE {$table}"); + } + + /** + * Analyze table statistics for query optimizer + */ + public function analyzeTable(string $table): void + { + DB::statement("ANALYZE TABLE {$table}"); + } + + /** + * Get table size and index statistics + */ + public function getTableStats(string $table): array + { + $stats = DB::select(" + SELECT + table_name, + ROUND(((data_length + index_length) / 1024 / 1024), 2) AS size_mb, + table_rows, + ROUND((data_length / 1024 / 1024), 2) AS data_size_mb, + ROUND((index_length / 1024 / 1024), 2) AS index_size_mb + FROM information_schema.TABLES + WHERE table_schema = DATABASE() + AND table_name = ? + ", [$table]); + + return $stats ? (array) $stats[0] : []; + } + + /** + * Get index usage statistics + */ + public function getIndexStats(string $table): array + { + $stats = DB::select(" + SELECT + index_name, + column_name, + seq_in_index, + cardinality, + index_type + FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = ? + ORDER BY index_name, seq_in_index + ", [$table]); + + return array_map(function ($row) { + return (array) $row; + }, $stats); + } + + /** + * Check for missing indexes based on slow queries + */ + public function suggestIndexes(string $table): array + { + $suggestions = []; + + // Get columns that are frequently used in WHERE clauses without indexes + $unindexedColumns = DB::select(" + SELECT DISTINCT column_name + FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name NOT IN ( + SELECT column_name + FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = ? + ) + ", [$table, $table]); + + foreach ($unindexedColumns as $column) { + $suggestions[] = [ + 'type' => 'missing_index', + 'table' => $table, + 'column' => $column->column_name, + 'suggestion' => "Consider adding an index on {$column->column_name}", + ]; + } + + return $suggestions; + } + + /** + * Chunk large queries to reduce memory usage + */ + public function chunkQuery(Builder $query, int $chunkSize, callable $callback): void + { + $query->chunk($chunkSize, $callback); + } + + /** + * Use cursor for memory-efficient iteration + */ + public function cursorQuery(Builder $query): \Illuminate\Support\LazyCollection + { + return $query->cursor(); + } +} diff --git a/database/migrations/2026_04_03_175345_add_performance_indexes_to_jobs_table.php b/database/migrations/2026_04_03_175345_add_performance_indexes_to_jobs_table.php new file mode 100644 index 0000000..33ead9d --- /dev/null +++ b/database/migrations/2026_04_03_175345_add_performance_indexes_to_jobs_table.php @@ -0,0 +1,60 @@ +index('posted_at', 'idx_jobs_posted_at'); + $table->index('job_type', 'idx_jobs_type'); + $table->index('experience_level', 'idx_jobs_experience'); + $table->index('remote_allowed', 'idx_jobs_remote'); + $table->index('created_at', 'idx_jobs_created_at'); + $table->index('expires_at', 'idx_jobs_expires_at'); + + // Composite indexes for common query patterns + $table->index(['job_category_id', 'posted_at'], 'idx_jobs_category_posted'); + $table->index(['job_type', 'posted_at'], 'idx_jobs_type_posted'); + $table->index(['remote_allowed', 'posted_at'], 'idx_jobs_remote_posted'); + $table->index(['job_source_id', 'posted_at'], 'idx_jobs_source_posted'); + $table->index(['expires_at', 'deleted_at'], 'idx_jobs_expires_deleted'); + + // Full-text index for search (if not already exists) + DB::statement('ALTER TABLE jobs ADD FULLTEXT idx_jobs_search (title, company, description)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('jobs', function (Blueprint $table) { + // Drop single column indexes + $table->dropIndex('idx_jobs_posted_at'); + $table->dropIndex('idx_jobs_type'); + $table->dropIndex('idx_jobs_experience'); + $table->dropIndex('idx_jobs_remote'); + $table->dropIndex('idx_jobs_created_at'); + $table->dropIndex('idx_jobs_expires_at'); + + // Drop composite indexes + $table->dropIndex('idx_jobs_category_posted'); + $table->dropIndex('idx_jobs_type_posted'); + $table->dropIndex('idx_jobs_remote_posted'); + $table->dropIndex('idx_jobs_source_posted'); + $table->dropIndex('idx_jobs_expires_deleted'); + + // Drop full-text index + DB::statement('ALTER TABLE jobs DROP INDEX idx_jobs_search'); + }); + } +};