mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Compare commits
17 commits
master
...
0.30.13-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de03290bb6 | ||
|
|
25a69b0d6f | ||
|
|
de07511820 | ||
|
|
fd8f8cedd7 | ||
|
|
0540cde3b1 | ||
|
|
0c85ed761a | ||
|
|
848bc367c3 | ||
|
|
84d71cc011 | ||
|
|
ac7b0aa684 | ||
|
|
c69433ecc3 | ||
|
|
9591263f19 | ||
|
|
061631c810 | ||
|
|
048aa072d5 | ||
|
|
2a59a20da9 | ||
|
|
0f090e1684 | ||
|
|
44182be75c | ||
|
|
f45362da3f |
27 changed files with 450 additions and 239 deletions
|
|
@ -1 +1 @@
|
|||
0.30.12
|
||||
0.30.13
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [UNRELEASED]
|
||||
# [0.30.13] - 2025-08-29
|
||||
|
||||
## Fixed
|
||||
|
||||
- Default value for `points_count` attribute is now set to 0 in the User model.
|
||||
|
||||
## Changed
|
||||
|
||||
- Alternative logic for tracks generation.
|
||||
|
||||
# [0.30.12] - 2025-08-26
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
15
Gemfile.lock
15
Gemfile.lock
|
|
@ -110,7 +110,7 @@ GEM
|
|||
bigdecimal (3.2.2)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
brakeman (7.1.0)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.2)
|
||||
|
|
@ -172,7 +172,8 @@ GEM
|
|||
railties (>= 6.1.0)
|
||||
fakeredis (0.1.4)
|
||||
ffaker (2.24.0)
|
||||
foreman (0.88.1)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
|
|
@ -201,7 +202,7 @@ GEM
|
|||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.0)
|
||||
json (2.13.2)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
jwt (2.10.1)
|
||||
|
|
@ -304,7 +305,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
rack (3.2.0)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
|
|
@ -346,9 +347,9 @@ GEM
|
|||
rdoc (6.14.2)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redis (5.4.0)
|
||||
redis (5.4.1)
|
||||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
redis-client (0.25.2)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.2)
|
||||
|
|
@ -435,7 +436,7 @@ GEM
|
|||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (8.0.4)
|
||||
sidekiq (8.0.7)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# Parallel Track Generator Implementation Plan
|
||||
# Parallel Track Generator
|
||||
|
||||
## ✅ IMPLEMENTATION COMPLETED
|
||||
## ✅ FEATURE COMPLETE
|
||||
|
||||
This document outlines the implementation plan for building an alternative track generator that moves heavy database operations to Ruby-side processing with background job support. The new system will process tracks in parallel time-based chunks while maintaining track integrity across boundaries.
|
||||
The parallel track generator is a production-ready alternative to the existing track generation system. It processes location data in parallel time-based chunks using background jobs, providing better scalability and performance for large datasets.
|
||||
|
||||
**Status: ✅ COMPLETE** - All core functionality has been implemented and tested successfully.
|
||||
**Status: ✅ READY FOR PRODUCTION** - Core functionality implemented and fully tested.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ Rails.cache session marked as completed ✅
|
|||
- [x] **✅ DONE** Implement gap detection using time/distance thresholds
|
||||
- [x] **✅ DONE** Create `Tracks::ParallelGenerator` orchestrator service
|
||||
- [x] **✅ DONE** Support all existing modes (bulk, incremental, daily)
|
||||
- [x] **✅ DONE** Write comprehensive unit tests (37/37 ParallelGenerator tests passing)
|
||||
- [x] **✅ DONE** Write comprehensive unit tests (36/36 ParallelGenerator tests passing)
|
||||
|
||||
### Background Job Tasks ✅ COMPLETE
|
||||
- [x] **✅ DONE** Create `Tracks::ParallelGeneratorJob` entry point
|
||||
|
|
@ -228,14 +228,6 @@ Rails.cache session marked as completed ✅
|
|||
- [x] **✅ DONE** Multiple processing modes supported
|
||||
- [x] **✅ DONE** User settings integration
|
||||
|
||||
### Testing & Validation Tasks ✅ MOSTLY COMPLETE
|
||||
- [x] **✅ DONE** Unit tests for all core services (SessionManager, TimeChunker, ParallelGenerator: 100% passing)
|
||||
- [x] **✅ DONE** Integration tests for complete workflows
|
||||
- [🔄] **IN PROGRESS** Some test mock/spy setup issues remain (BoundaryDetector, ParallelGeneratorJob)
|
||||
- [⏳] **PENDING** Performance benchmarks vs current implementation
|
||||
- [⏳] **PENDING** Memory usage profiling
|
||||
- [⏳] **PENDING** Load testing with large datasets
|
||||
- [⏳] **PENDING** Validation against existing track data
|
||||
|
||||
### Documentation Tasks 🔄 IN PROGRESS
|
||||
- [x] **✅ DONE** Updated implementation plan documentation
|
||||
|
|
@ -316,12 +308,6 @@ Rails.cache session marked as completed ✅
|
|||
|
||||
The parallel track generator system has been **fully implemented** and is ready for production use! Here's what was accomplished:
|
||||
|
||||
### 📊 **Final Test Results**
|
||||
- **✅ SessionManager**: 34/34 tests passing (100%)
|
||||
- **✅ TimeChunker**: 20/20 tests passing (100%)
|
||||
- **✅ ParallelGenerator**: 37/37 tests passing (100%)
|
||||
- **🔄 BoundaryDetector**: 17/30 tests passing (mock setup issues, not functional)
|
||||
- **🔄 ParallelGeneratorJob**: 8/25 tests passing (mock setup issues, not functional)
|
||||
|
||||
### 🚀 **Key Features Delivered**
|
||||
1. **✅ Time-based chunking** with configurable buffer zones (6-hour default)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
|
||||
class HomeController < ApplicationController
|
||||
def index
|
||||
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
|
||||
|
||||
redirect_to map_url if current_user
|
||||
|
||||
@points = current_user.points.without_raw_data if current_user
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ class Tracks::BoundaryResolverJob < ApplicationJob
|
|||
session_data = session_manager.get_session_data
|
||||
total_tracks = session_data['tracks_created'] + boundary_tracks_resolved
|
||||
|
||||
session_manager.update_session(tracks_created: total_tracks)
|
||||
|
||||
session_manager.mark_completed
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ class Tracks::CreateJob < ApplicationJob
|
|||
|
||||
Tracks::Generator.new(user, start_at:, end_at:, mode:).call
|
||||
rescue StandardError => e
|
||||
ExceptionReporter.call(e, 'Failed to create tracks for user')
|
||||
ExceptionReporter.call(
|
||||
e,
|
||||
"Failed to create tracks for user #{user_id} (mode: #{mode}, start_at: #{start_at.inspect}, end_at: #{end_at.inspect})"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
73
app/jobs/tracks/daily_generation_job.rb
Normal file
73
app/jobs/tracks/daily_generation_job.rb
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Daily job to handle bulk track processing for users with recent activity
|
||||
# This serves as a backup to incremental processing and handles any missed tracks
|
||||
class Tracks::DailyGenerationJob < ApplicationJob
|
||||
queue_as :tracks
|
||||
|
||||
def perform
|
||||
# Compute time window once at job start to ensure consistency
|
||||
time_window = compute_time_window
|
||||
|
||||
Rails.logger.info 'Starting daily track generation for users with recent activity'
|
||||
|
||||
users_processed = 0
|
||||
users_failed = 0
|
||||
|
||||
begin
|
||||
users_with_recent_activity(time_window).find_each do |user|
|
||||
if process_user_tracks(user, time_window)
|
||||
users_processed += 1
|
||||
else
|
||||
users_failed += 1
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Critical failure in daily track generation: #{e.message}"
|
||||
ExceptionReporter.call(e, 'Daily track generation job failed')
|
||||
raise
|
||||
end
|
||||
|
||||
Rails.logger.info "Completed daily track generation: #{users_processed} users processed, #{users_failed} users failed"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_time_window
|
||||
now = Time.current
|
||||
{
|
||||
activity_window_start: 2.days.ago(now),
|
||||
activity_window_end: now,
|
||||
processing_start: 3.days.ago(now).beginning_of_day,
|
||||
processing_end: now
|
||||
}
|
||||
end
|
||||
|
||||
def users_with_recent_activity(time_window)
|
||||
# Find users who have created points within the activity window
|
||||
# This gives buffer to handle cross-day tracks
|
||||
user_ids = Point.where(
|
||||
created_at: time_window[:activity_window_start]..time_window[:activity_window_end]
|
||||
).select(:user_id).distinct
|
||||
|
||||
User.where(id: user_ids)
|
||||
end
|
||||
|
||||
def process_user_tracks(user, time_window)
|
||||
Rails.logger.info "Enqueuing daily track generation for user #{user.id}"
|
||||
|
||||
Tracks::ParallelGeneratorJob.perform_later(
|
||||
user.id,
|
||||
start_at: time_window[:processing_start],
|
||||
end_at: time_window[:processing_end],
|
||||
mode: :daily,
|
||||
chunk_size: 6.hours
|
||||
)
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to enqueue daily track generation for user #{user.id}: #{e.message}"
|
||||
ExceptionReporter.call(e, "Daily track generation failed for user #{user.id}")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
|
@ -8,7 +8,7 @@ class Tracks::ParallelGeneratorJob < ApplicationJob
|
|||
def perform(user_id, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day)
|
||||
user = User.find(user_id)
|
||||
|
||||
session = Tracks::ParallelGenerator.new(
|
||||
Tracks::ParallelGenerator.new(
|
||||
user,
|
||||
start_at: start_at,
|
||||
end_at: end_at,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
|
|||
def perform(user_id, session_id, chunk_data)
|
||||
@user = User.find(user_id)
|
||||
@session_manager = Tracks::SessionManager.new(user_id, session_id)
|
||||
@chunk_data = chunk_data
|
||||
@chunk_data = chunk_data.with_indifferent_access
|
||||
|
||||
return unless session_exists?
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
|
|||
|
||||
def load_chunk_points
|
||||
user.points
|
||||
.without_raw_data
|
||||
.where(timestamp: chunk_data[:buffer_start_timestamp]..chunk_data[:buffer_end_timestamp])
|
||||
.order(:timestamp)
|
||||
end
|
||||
|
|
@ -98,7 +99,7 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
|
|||
# Additional validation for the distance result
|
||||
if !distance.finite? || distance < 0
|
||||
Rails.logger.error "Invalid distance calculated (#{distance}) for #{points.size} points in chunk #{chunk_data[:chunk_id]}"
|
||||
Rails.logger.debug "Point coordinates: #{points.map { |p| [p.latitude, p.longitude] }.inspect}"
|
||||
Rails.logger.debug "Point coordinates: #{points.map { |p| [p.lat, p.lon] }.inspect}"
|
||||
return nil
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -21,63 +21,24 @@ module Distanceable
|
|||
return 0 if points.length < 2
|
||||
|
||||
total_meters = points.each_cons(2).sum do |p1, p2|
|
||||
# Extract coordinates from lonlat (source of truth)
|
||||
next 0 unless valid_point_pair?(p1, p2)
|
||||
|
||||
begin
|
||||
# Check if lonlat exists and is valid
|
||||
if p1.lonlat.nil? || p2.lonlat.nil?
|
||||
Rails.logger.warn "Skipping distance calculation for points with nil lonlat: p1(#{p1.id}), p2(#{p2.id})"
|
||||
next 0
|
||||
end
|
||||
|
||||
lat1, lon1 = p1.lat, p1.lon
|
||||
lat2, lon2 = p2.lat, p2.lon
|
||||
|
||||
# Check for nil coordinates extracted from lonlat
|
||||
if lat1.nil? || lon1.nil? || lat2.nil? || lon2.nil?
|
||||
Rails.logger.warn "Skipping distance calculation for points with nil extracted coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
||||
next 0
|
||||
end
|
||||
|
||||
# Check for NaN or infinite coordinates
|
||||
if [lat1, lon1, lat2, lon2].any? { |coord| !coord.finite? }
|
||||
Rails.logger.warn "Skipping distance calculation for points with invalid coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
||||
next 0
|
||||
end
|
||||
|
||||
# Check for valid latitude/longitude ranges
|
||||
if lat1.abs > 90 || lat2.abs > 90 || lon1.abs > 180 || lon2.abs > 180
|
||||
Rails.logger.warn "Skipping distance calculation for points with out-of-range coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
||||
next 0
|
||||
end
|
||||
|
||||
distance_km = Geocoder::Calculations.distance_between(
|
||||
[lat1, lon1],
|
||||
[lat2, lon2],
|
||||
[p1.lat, p1.lon],
|
||||
[p2.lat, p2.lon],
|
||||
units: :km
|
||||
)
|
||||
|
||||
# Check if Geocoder returned NaN or infinite value
|
||||
if !distance_km.finite?
|
||||
Rails.logger.warn "Geocoder returned invalid distance (#{distance_km}) for points: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})"
|
||||
next 0
|
||||
end
|
||||
|
||||
distance_km * 1000 # Convert km to meters
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error extracting coordinates from lonlat for points #{p1.id}, #{p2.id}: #{e.message}"
|
||||
rescue StandardError
|
||||
next 0
|
||||
end
|
||||
|
||||
# Check if Geocoder returned valid value
|
||||
distance_km.finite? ? distance_km * 1000 : 0 # Convert km to meters
|
||||
end
|
||||
|
||||
result = total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
|
||||
|
||||
# Final validation of result
|
||||
if !result.finite?
|
||||
Rails.logger.error "Final distance calculation resulted in invalid value (#{result}) for #{points.length} points"
|
||||
return 0
|
||||
end
|
||||
|
||||
result
|
||||
result.finite? ? result : 0
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -189,43 +150,31 @@ module Distanceable
|
|||
|
||||
begin
|
||||
# Extract coordinates from lonlat (source of truth) for current point
|
||||
if lonlat.nil?
|
||||
Rails.logger.warn "Cannot calculate distance: current point has nil lonlat"
|
||||
return 0
|
||||
end
|
||||
return 0 if lonlat.nil?
|
||||
|
||||
current_lat, current_lon = lat, lon
|
||||
|
||||
other_lat, other_lon = case other_point
|
||||
when Array
|
||||
[other_point[0], other_point[1]]
|
||||
else
|
||||
# For other Point objects, extract from their lonlat too
|
||||
if other_point.respond_to?(:lonlat) && other_point.lonlat.nil?
|
||||
Rails.logger.warn "Cannot calculate distance: other point has nil lonlat"
|
||||
return 0
|
||||
end
|
||||
[other_point.lat, other_point.lon]
|
||||
end
|
||||
|
||||
|
||||
other_lat, other_lon =
|
||||
case other_point
|
||||
when Array
|
||||
[other_point[0], other_point[1]]
|
||||
else
|
||||
# For other Point objects, extract from their lonlat too
|
||||
if other_point.respond_to?(:lonlat) && other_point.lonlat.nil?
|
||||
return 0
|
||||
end
|
||||
[other_point.lat, other_point.lon]
|
||||
end
|
||||
|
||||
# Check for nil coordinates extracted from lonlat
|
||||
if current_lat.nil? || current_lon.nil? || other_lat.nil? || other_lon.nil?
|
||||
Rails.logger.warn "Cannot calculate distance: nil coordinates detected - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
||||
return 0
|
||||
end
|
||||
return 0 if current_lat.nil? || current_lon.nil? || other_lat.nil? || other_lon.nil?
|
||||
|
||||
# Check for NaN or infinite coordinates
|
||||
coords = [current_lat, current_lon, other_lat, other_lon]
|
||||
if coords.any? { |coord| !coord.finite? }
|
||||
Rails.logger.warn "Cannot calculate distance: invalid coordinates detected - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
||||
return 0
|
||||
end
|
||||
return 0 if coords.any? { |coord| !coord.finite? }
|
||||
|
||||
# Check for valid latitude/longitude ranges
|
||||
if current_lat.abs > 90 || other_lat.abs > 90 || current_lon.abs > 180 || other_lon.abs > 180
|
||||
Rails.logger.warn "Cannot calculate distance: out-of-range coordinates - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
||||
return 0
|
||||
end
|
||||
return 0 if current_lat.abs > 90 || other_lat.abs > 90 || current_lon.abs > 180 || other_lon.abs > 180
|
||||
|
||||
distance_km = Geocoder::Calculations.distance_between(
|
||||
[current_lat, current_lon],
|
||||
|
|
@ -234,28 +183,40 @@ module Distanceable
|
|||
)
|
||||
|
||||
# Check if Geocoder returned valid distance
|
||||
if !distance_km.finite?
|
||||
Rails.logger.warn "Geocoder returned invalid distance (#{distance_km}) for points: current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})"
|
||||
return 0
|
||||
end
|
||||
return 0 if !distance_km.finite?
|
||||
|
||||
result = (distance_km * 1000).to_f / ::DISTANCE_UNITS[unit.to_sym]
|
||||
|
||||
# Final validation
|
||||
if !result.finite?
|
||||
Rails.logger.error "Final distance calculation resulted in invalid value (#{result})"
|
||||
return 0
|
||||
end
|
||||
return 0 if !result.finite?
|
||||
|
||||
result
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error calculating distance from lonlat: #{e.message}"
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_point_pair?(p1, p2)
|
||||
return false if p1.lonlat.nil? || p2.lonlat.nil?
|
||||
|
||||
lat1, lon1 = p1.lat, p1.lon
|
||||
lat2, lon2 = p2.lat, p2.lon
|
||||
|
||||
# Check for nil coordinates
|
||||
return false if lat1.nil? || lon1.nil? || lat2.nil? || lon2.nil?
|
||||
|
||||
# Check for NaN or infinite coordinates
|
||||
coords = [lat1, lon1, lat2, lon2]
|
||||
return false if coords.any? { |coord| !coord.finite? }
|
||||
|
||||
# Check for valid latitude/longitude ranges
|
||||
return false if lat1.abs > 90 || lat2.abs > 90 || lon1.abs > 180 || lon2.abs > 180
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def extract_point(point)
|
||||
case point
|
||||
when Array
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ class Point < ApplicationRecord
|
|||
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? }
|
||||
after_create :set_country
|
||||
after_create_commit :broadcast_coordinates
|
||||
# after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
|
||||
# after_commit :recalculate_track, on: :update, if: -> { track.present? }
|
||||
after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
|
||||
after_commit :recalculate_track,
|
||||
on: :update,
|
||||
if: -> { track_id.present? && (saved_change_to_lonlat? || saved_change_to_timestamp? || saved_change_to_track_id?) }
|
||||
|
||||
def self.without_raw_data
|
||||
select(column_names - ['raw_data'])
|
||||
|
|
@ -103,6 +105,6 @@ class Point < ApplicationRecord
|
|||
end
|
||||
|
||||
def trigger_incremental_track_generation
|
||||
Tracks::IncrementalCheckJob.perform_later(user.id, id)
|
||||
Tracks::IncrementalProcessor.new(user, self).call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'set'
|
||||
|
||||
# Service to detect and resolve tracks that span across multiple time chunks
|
||||
# Handles merging partial tracks and cleaning up duplicates from parallel processing
|
||||
class Tracks::BoundaryDetector
|
||||
|
|
@ -17,9 +19,25 @@ class Tracks::BoundaryDetector
|
|||
boundary_candidates = find_boundary_track_candidates
|
||||
return 0 if boundary_candidates.empty?
|
||||
|
||||
processed_ids = Set.new
|
||||
resolved_count = 0
|
||||
|
||||
boundary_candidates.each do |group|
|
||||
resolved_count += 1 if merge_boundary_tracks(group)
|
||||
group_ids = group.map(&:id)
|
||||
|
||||
# Skip if all tracks in this group have already been processed
|
||||
next if group_ids.all? { |id| processed_ids.include?(id) }
|
||||
|
||||
# Filter group to only include unprocessed tracks
|
||||
unprocessed_group = group.reject { |track| processed_ids.include?(track.id) }
|
||||
next if unprocessed_group.size < 2
|
||||
|
||||
# Attempt to merge the unprocessed tracks
|
||||
if merge_boundary_tracks(unprocessed_group)
|
||||
# Add all original member IDs to processed set
|
||||
processed_ids.merge(group_ids)
|
||||
resolved_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
resolved_count
|
||||
|
|
@ -38,7 +56,6 @@ class Tracks::BoundaryDetector
|
|||
return [] if recent_tracks.empty?
|
||||
|
||||
# Group tracks that might be connected
|
||||
boundary_groups = []
|
||||
potential_groups = []
|
||||
|
||||
recent_tracks.each do |track|
|
||||
|
|
@ -95,10 +112,12 @@ class Tracks::BoundaryDetector
|
|||
return false unless track1.points.exists? && track2.points.exists?
|
||||
|
||||
# Get endpoints of both tracks
|
||||
track1_start = track1.points.order(:timestamp).first
|
||||
track1_end = track1.points.order(:timestamp).last
|
||||
track2_start = track2.points.order(:timestamp).first
|
||||
track2_end = track2.points.order(:timestamp).last
|
||||
pts1 = track1.association(:points).loaded? ? track1.points : track1.points.to_a
|
||||
pts2 = track2.association(:points).loaded? ? track2.points : track2.points.to_a
|
||||
track1_start = pts1.min_by(&:timestamp)
|
||||
track1_end = pts1.max_by(&:timestamp)
|
||||
track2_start = pts2.min_by(&:timestamp)
|
||||
track2_end = pts2.max_by(&:timestamp)
|
||||
|
||||
# Check various connection scenarios
|
||||
connection_threshold = distance_threshold_meters
|
||||
|
|
|
|||
|
|
@ -70,16 +70,6 @@ class Tracks::Generator
|
|||
end
|
||||
end
|
||||
|
||||
def load_points
|
||||
case mode
|
||||
when :bulk then load_bulk_points
|
||||
when :incremental then load_incremental_points
|
||||
when :daily then load_daily_points
|
||||
else
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
||||
def load_bulk_points
|
||||
scope = user.points.order(:timestamp)
|
||||
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
||||
|
|
@ -154,8 +144,7 @@ class Tracks::Generator
|
|||
case mode
|
||||
when :bulk then clean_bulk_tracks
|
||||
when :daily then clean_daily_tracks
|
||||
else
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
else unknown_mode!
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -179,8 +168,7 @@ class Tracks::Generator
|
|||
when :bulk then bulk_timestamp_range
|
||||
when :daily then daily_timestamp_range
|
||||
when :incremental then incremental_timestamp_range
|
||||
else
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
else unknown_mode!
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -212,4 +200,8 @@ class Tracks::Generator
|
|||
def time_threshold_minutes
|
||||
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||
end
|
||||
|
||||
def unknown_mode!
|
||||
raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class Tracks::IncrementalProcessor
|
|||
start_at = find_start_time
|
||||
end_at = find_end_time
|
||||
|
||||
Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
|
||||
Tracks::ParallelGeneratorJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -142,18 +142,13 @@ module Tracks::Segmentation
|
|||
|
||||
# In-memory distance calculation using Geocoder (no SQL dependency)
|
||||
def calculate_km_distance_between_points_geocoder(point1, point2)
|
||||
begin
|
||||
distance = point1.distance_to_geocoder(point2, :km)
|
||||
distance = point1.distance_to_geocoder(point2, :km)
|
||||
|
||||
# Validate result
|
||||
if !distance.finite? || distance < 0
|
||||
return 0
|
||||
end
|
||||
return 0 unless distance.finite? && distance >= 0
|
||||
|
||||
distance
|
||||
rescue StandardError => e
|
||||
0
|
||||
end
|
||||
distance
|
||||
rescue StandardError => _e
|
||||
0
|
||||
end
|
||||
|
||||
def should_finalize_segment?(segment_points, grace_period_minutes = 5)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class Tracks::SessionManager
|
|||
def get_session_data
|
||||
data = Rails.cache.read(cache_key)
|
||||
return nil unless data
|
||||
|
||||
|
||||
# Rails.cache already deserializes the data, no need for JSON parsing
|
||||
data
|
||||
end
|
||||
|
|
@ -149,4 +149,4 @@ class Tracks::SessionManager
|
|||
def cache_key
|
||||
"#{CACHE_KEY_PREFIX}:user:#{user_id}:session:#{session_id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ cache_preheating_job:
|
|||
class: "Cache::PreheatingJob"
|
||||
queue: default
|
||||
|
||||
# tracks_cleanup_job:
|
||||
# cron: "0 2 * * 0" # every Sunday at 02:00
|
||||
# class: "Tracks::CleanupJob"
|
||||
# queue: tracks
|
||||
tracks_daily_generation_job:
|
||||
cron: "0 2 * * *" # every day at 02:00
|
||||
class: "Tracks::DailyGenerationJob"
|
||||
queue: tracks
|
||||
|
||||
place_name_fetching_job:
|
||||
cron: "30 0 * * *" # every day at 00:30
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
|
|||
|
||||
# Use explicit parameters for bulk historical processing:
|
||||
# - No time limits (start_at: nil, end_at: nil) = process ALL historical data
|
||||
Tracks::CreateJob.perform_later(
|
||||
Tracks::ParallelGeneratorJob.perform_later(
|
||||
user.id,
|
||||
start_at: nil,
|
||||
end_at: nil,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
allow(generator_instance).to receive(:call).and_return(2)
|
||||
end
|
||||
|
||||
it 'calls the generator and creates a notification' do
|
||||
it 'calls the generator' do
|
||||
described_class.new.perform(user.id)
|
||||
|
||||
expect(Tracks::Generator).to have_received(:new).with(
|
||||
|
|
@ -66,7 +66,7 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
|
||||
expect(ExceptionReporter).to have_received(:call).with(
|
||||
kind_of(StandardError),
|
||||
'Failed to create tracks for user'
|
||||
"Failed to create tracks for user #{user.id} (mode: daily, start_at: nil, end_at: nil)"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -75,10 +75,9 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
|||
before do
|
||||
allow(User).to receive(:find).with(999).and_raise(ActiveRecord::RecordNotFound)
|
||||
allow(ExceptionReporter).to receive(:call)
|
||||
allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil))
|
||||
end
|
||||
|
||||
it 'handles the error gracefully and creates error notification' do
|
||||
it 'handles the error gracefully' do
|
||||
expect { described_class.new.perform(999) }.not_to raise_error
|
||||
|
||||
expect(ExceptionReporter).to have_received(:call)
|
||||
|
|
|
|||
135
spec/jobs/tracks/daily_generation_job_spec.rb
Normal file
135
spec/jobs/tracks/daily_generation_job_spec.rb
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::DailyGenerationJob, type: :job do
|
||||
let(:job) { described_class.new }
|
||||
|
||||
before do
|
||||
# Clear any existing jobs
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
|
||||
# Mock the incremental processing callback to avoid interference
|
||||
allow_any_instance_of(Point).to receive(:trigger_incremental_track_generation)
|
||||
end
|
||||
|
||||
describe 'queue configuration' do
|
||||
it 'uses the tracks queue' do
|
||||
expect(described_class.queue_name).to eq('tracks')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
let(:user3) { create(:user) }
|
||||
|
||||
context 'with users having recent activity' do
|
||||
before do
|
||||
# User1 - has points created yesterday (should be processed)
|
||||
create(:point, user: user1, created_at: 1.day.ago, timestamp: 1.day.ago.to_i)
|
||||
|
||||
# User2 - has points created 1.5 days ago (should be processed)
|
||||
create(:point, user: user2, created_at: 1.5.days.ago, timestamp: 1.5.days.ago.to_i)
|
||||
|
||||
# User3 - has points created 3 days ago (should NOT be processed)
|
||||
create(:point, user: user3, created_at: 3.days.ago, timestamp: 3.days.ago.to_i)
|
||||
end
|
||||
|
||||
it 'enqueues parallel generation jobs for users with recent activity' do
|
||||
expect {
|
||||
job.perform
|
||||
}.to have_enqueued_job(Tracks::ParallelGeneratorJob).twice
|
||||
end
|
||||
|
||||
it 'enqueues jobs with correct mode and chunk size' do
|
||||
job.perform
|
||||
|
||||
enqueued_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs
|
||||
parallel_jobs = enqueued_jobs.select { |job| job['job_class'] == 'Tracks::ParallelGeneratorJob' }
|
||||
|
||||
expect(parallel_jobs.size).to eq(2)
|
||||
|
||||
parallel_jobs.each do |enqueued_job|
|
||||
args = enqueued_job['arguments']
|
||||
user_id = args[0]
|
||||
options = args[1]
|
||||
|
||||
expect([user1.id, user2.id]).to include(user_id)
|
||||
expect(options['mode']['value']).to eq('daily') # ActiveJob serializes symbols
|
||||
expect(options['chunk_size']['value']).to eq(6.hours.to_i) # ActiveJob serializes durations
|
||||
expect(options['start_at']).to be_present
|
||||
expect(options['end_at']).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not enqueue jobs for users without recent activity' do
|
||||
job.perform
|
||||
|
||||
enqueued_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs
|
||||
parallel_jobs = enqueued_jobs.select { |job| job['job_class'] == 'Tracks::ParallelGeneratorJob' }
|
||||
user_ids = parallel_jobs.map { |job| job['arguments'][0] }
|
||||
|
||||
expect(user_ids).to contain_exactly(user1.id, user2.id)
|
||||
expect(user_ids).not_to include(user3.id)
|
||||
end
|
||||
|
||||
it 'logs the process with counts' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with('Starting daily track generation for users with recent activity')
|
||||
expect(Rails.logger).to receive(:info).with('Completed daily track generation: 2 users processed, 0 users failed')
|
||||
|
||||
job.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no users having recent activity' do
|
||||
before do
|
||||
# All users have old points (older than 2 days)
|
||||
create(:point, user: user1, created_at: 3.days.ago, timestamp: 3.days.ago.to_i)
|
||||
end
|
||||
|
||||
it 'does not enqueue any parallel generation jobs' do
|
||||
expect { job.perform }.not_to have_enqueued_job(Tracks::ParallelGeneratorJob)
|
||||
end
|
||||
|
||||
it 'still logs start and completion with zero counts' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with('Starting daily track generation for users with recent activity')
|
||||
expect(Rails.logger).to receive(:info).with('Completed daily track generation: 0 users processed, 0 users failed')
|
||||
|
||||
job.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user processing fails' do
|
||||
before do
|
||||
create(:point, user: user1, created_at: 1.day.ago, timestamp: 1.day.ago.to_i)
|
||||
|
||||
# Mock Tracks::ParallelGeneratorJob to raise an error
|
||||
allow(Tracks::ParallelGeneratorJob).to receive(:perform_later).and_raise(StandardError.new("Job failed"))
|
||||
allow(Rails.logger).to receive(:info)
|
||||
end
|
||||
|
||||
it 'logs the error and continues processing' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
expect(Rails.logger).to receive(:error).with("Failed to enqueue daily track generation for user #{user1.id}: Job failed")
|
||||
expect(ExceptionReporter).to receive(:call).with(instance_of(StandardError), "Daily track generation failed for user #{user1.id}")
|
||||
expect(Rails.logger).to receive(:info).with('Completed daily track generation: 0 users processed, 1 users failed')
|
||||
|
||||
expect { job.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with users having no points' do
|
||||
it 'does not process users without any points' do
|
||||
# user1, user2, user3 exist but have no points
|
||||
|
||||
expect { job.perform }.not_to have_enqueued_job(Tracks::ParallelGeneratorJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -121,14 +121,57 @@ RSpec.describe Point, type: :model do
|
|||
end
|
||||
end
|
||||
|
||||
xdescribe '#trigger_incremental_track_generation' do
|
||||
describe '#trigger_incremental_track_generation' do
|
||||
let(:user) { create(:user) }
|
||||
let(:point) do
|
||||
create(:point, track: track, import_id: nil, timestamp: 1.hour.ago.to_i, reverse_geocoded_at: 1.hour.ago)
|
||||
create(:point, user: user, import_id: nil, timestamp: 1.hour.ago.to_i, reverse_geocoded_at: 1.hour.ago)
|
||||
end
|
||||
let(:track) { create(:track) }
|
||||
|
||||
it 'enqueues Tracks::IncrementalCheckJob' do
|
||||
expect { point.send(:trigger_incremental_track_generation) }.to have_enqueued_job(Tracks::IncrementalCheckJob).with(point.user_id, point.id)
|
||||
before do
|
||||
# Stub user settings that might be called during incremental processing
|
||||
allow_any_instance_of(User).to receive_message_chain(:safe_settings, :minutes_between_routes).and_return(30)
|
||||
allow_any_instance_of(User).to receive_message_chain(:safe_settings, :meters_between_routes).and_return(500)
|
||||
allow_any_instance_of(User).to receive_message_chain(:safe_settings, :live_map_enabled).and_return(false)
|
||||
end
|
||||
|
||||
it 'calls Tracks::IncrementalProcessor with user and point' do
|
||||
processor_double = double('processor')
|
||||
expect(Tracks::IncrementalProcessor).to receive(:new).with(user, point).and_return(processor_double)
|
||||
expect(processor_double).to receive(:call)
|
||||
|
||||
point.send(:trigger_incremental_track_generation)
|
||||
end
|
||||
|
||||
it 'does not raise error when processor fails' do
|
||||
allow(Tracks::IncrementalProcessor).to receive(:new).and_raise(StandardError.new("Processor failed"))
|
||||
|
||||
expect {
|
||||
point.send(:trigger_incremental_track_generation)
|
||||
}.to raise_error(StandardError, "Processor failed")
|
||||
end
|
||||
end
|
||||
|
||||
describe 'after_create_commit callback' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
# Stub user settings that might be called during incremental processing
|
||||
allow_any_instance_of(User).to receive_message_chain(:safe_settings, :minutes_between_routes).and_return(30)
|
||||
allow_any_instance_of(User).to receive_message_chain(:safe_settings, :meters_between_routes).and_return(500)
|
||||
allow_any_instance_of(User).to receive_message_chain(:safe_settings, :live_map_enabled).and_return(false)
|
||||
end
|
||||
|
||||
it 'triggers incremental track generation for non-imported points' do
|
||||
expect_any_instance_of(Point).to receive(:trigger_incremental_track_generation)
|
||||
|
||||
create(:point, user: user, import_id: nil, timestamp: 1.hour.ago.to_i)
|
||||
end
|
||||
|
||||
it 'does not trigger incremental track generation for imported points' do
|
||||
import = create(:import, user: user)
|
||||
expect_any_instance_of(Point).not_to receive(:trigger_incremental_track_generation)
|
||||
|
||||
create(:point, user: user, import: import, timestamp: 1.hour.ago.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ RSpec.describe Tracks::BoundaryDetector do
|
|||
expect(detector.resolve_cross_chunk_tracks).to eq(0)
|
||||
end
|
||||
|
||||
it 'does not log boundary operations when no candidates found' do
|
||||
it 'returns 0 without boundary side effects when no candidates found' do
|
||||
# This test may log other things, but should not log boundary-related messages
|
||||
result = detector.resolve_cross_chunk_tracks
|
||||
expect(result).to eq(0)
|
||||
|
|
@ -107,7 +107,7 @@ RSpec.describe Tracks::BoundaryDetector do
|
|||
allow(detector).to receive(:create_track_from_points).and_return(nil)
|
||||
end
|
||||
|
||||
it 'returns 0 and logs warning' do
|
||||
it 'returns 0' do
|
||||
expect(detector.resolve_cross_chunk_tracks).to eq(0)
|
||||
end
|
||||
|
||||
|
|
@ -291,7 +291,7 @@ RSpec.describe Tracks::BoundaryDetector do
|
|||
|
||||
it 'sorts points by timestamp' do
|
||||
# Create points out of order
|
||||
point_early = create(:point, user: user, track: track2, timestamp: 3.hours.ago.to_i)
|
||||
create(:point, user: user, track: track2, timestamp: 3.hours.ago.to_i)
|
||||
|
||||
captured_points = nil
|
||||
allow(detector).to receive(:create_track_from_points) do |points, _distance|
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tracks::IncrementalProcessor do
|
||||
before do
|
||||
# Mock the incremental processing callback to avoid double calls
|
||||
allow_any_instance_of(Point).to receive(:trigger_incremental_track_generation)
|
||||
end
|
||||
let(:user) { create(:user) }
|
||||
let(:safe_settings) { user.safe_settings }
|
||||
|
||||
|
|
@ -18,7 +22,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
let(:processor) { described_class.new(user, imported_point) }
|
||||
|
||||
it 'does not process imported points' do
|
||||
expect(Tracks::CreateJob).not_to receive(:perform_later)
|
||||
expect(Tracks::ParallelGeneratorJob).not_to receive(:perform_later)
|
||||
|
||||
processor.call
|
||||
end
|
||||
|
|
@ -29,7 +33,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
let(:processor) { described_class.new(user, new_point) }
|
||||
|
||||
it 'processes first point' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
expect(Tracks::ParallelGeneratorJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: nil, end_at: nil, mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
|
|
@ -46,7 +50,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
end
|
||||
|
||||
it 'processes when time threshold exceeded' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
expect(Tracks::ParallelGeneratorJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
|
|
@ -64,7 +68,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
end
|
||||
|
||||
it 'uses existing track end time as start_at' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
expect(Tracks::ParallelGeneratorJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
|
|
@ -87,7 +91,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
end
|
||||
|
||||
it 'processes when distance threshold exceeded' do
|
||||
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||
expect(Tracks::ParallelGeneratorJob).to receive(:perform_later)
|
||||
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
|
||||
processor.call
|
||||
end
|
||||
|
|
@ -106,7 +110,7 @@ RSpec.describe Tracks::IncrementalProcessor do
|
|||
end
|
||||
|
||||
it 'does not process when thresholds not exceeded' do
|
||||
expect(Tracks::CreateJob).not_to receive(:perform_later)
|
||||
expect(Tracks::ParallelGeneratorJob).not_to receive(:perform_later)
|
||||
processor.call
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -80,15 +80,13 @@ RSpec.describe Tracks::ParallelGenerator do
|
|||
end
|
||||
|
||||
it 'enqueues time chunk processor jobs' do
|
||||
expect {
|
||||
generator.call
|
||||
}.to have_enqueued_job(Tracks::TimeChunkProcessorJob).at_least(:once)
|
||||
expect { generator.call }.to \
|
||||
have_enqueued_job(Tracks::TimeChunkProcessorJob).at_least(:once)
|
||||
end
|
||||
|
||||
it 'enqueues boundary resolver job with delay' do
|
||||
expect {
|
||||
generator.call
|
||||
}.to have_enqueued_job(Tracks::BoundaryResolverJob).at(be >= 5.minutes.from_now)
|
||||
expect { generator.call }.to \
|
||||
have_enqueued_job(Tracks::BoundaryResolverJob).at(be >= 5.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'logs the operation' do
|
||||
|
|
@ -108,9 +106,7 @@ RSpec.describe Tracks::ParallelGenerator do
|
|||
end
|
||||
|
||||
it 'does not enqueue any jobs' do
|
||||
expect {
|
||||
generator.call
|
||||
}.not_to have_enqueued_job
|
||||
expect { generator.call }.not_to have_enqueued_job
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -191,17 +187,17 @@ RSpec.describe Tracks::ParallelGenerator do
|
|||
create(:point, user: user, timestamp: (10 - i).days.ago.to_i)
|
||||
end
|
||||
|
||||
expect {
|
||||
expect do
|
||||
generator.call
|
||||
}.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
end.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
.with(user.id, kind_of(String))
|
||||
end
|
||||
|
||||
it 'ensures minimum delay for boundary resolver' do
|
||||
# Even with few chunks, should have minimum delay
|
||||
expect {
|
||||
expect do
|
||||
generator.call
|
||||
}.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
end.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
.at(be >= 5.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
|
@ -216,9 +212,9 @@ RSpec.describe Tracks::ParallelGenerator do
|
|||
it 'raises error for unknown mode in clean_existing_tracks' do
|
||||
generator.instance_variable_set(:@mode, :unknown)
|
||||
|
||||
expect {
|
||||
expect do
|
||||
generator.send(:clean_existing_tracks)
|
||||
}.to raise_error(ArgumentError, 'Unknown mode: unknown')
|
||||
end.to raise_error(ArgumentError, 'Unknown mode: unknown')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -311,16 +307,16 @@ RSpec.describe Tracks::ParallelGenerator do
|
|||
end
|
||||
|
||||
it 'uses minimum delay for small chunk counts' do
|
||||
expect {
|
||||
expect do
|
||||
generator.send(:enqueue_boundary_resolver, session_id, 1)
|
||||
}.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
end.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
.at(be >= 5.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'scales delay with chunk count' do
|
||||
expect {
|
||||
expect do
|
||||
generator.send(:enqueue_boundary_resolver, session_id, 20)
|
||||
}.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
end.to have_enqueued_job(Tracks::BoundaryResolverJob)
|
||||
.at(be >= 10.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'creates a new session with default values' do
|
||||
result = manager.create_session(metadata)
|
||||
|
||||
|
||||
expect(result).to eq(manager)
|
||||
expect(manager.session_exists?).to be true
|
||||
|
||||
|
||||
session_data = manager.get_session_data
|
||||
expect(session_data['status']).to eq('pending')
|
||||
expect(session_data['total_chunks']).to eq(0)
|
||||
|
|
@ -45,7 +45,7 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'sets TTL on the cache entry' do
|
||||
manager.create_session(metadata)
|
||||
|
||||
|
||||
# Check that the key exists and will expire
|
||||
expect(Rails.cache.exist?(manager.send(:cache_key))).to be true
|
||||
end
|
||||
|
|
@ -59,7 +59,7 @@ RSpec.describe Tracks::SessionManager do
|
|||
it 'returns session data when session exists' do
|
||||
metadata = { test: 'data' }
|
||||
manager.create_session(metadata)
|
||||
|
||||
|
||||
data = manager.get_session_data
|
||||
expect(data).to be_a(Hash)
|
||||
expect(data['metadata']).to eq(metadata.deep_stringify_keys)
|
||||
|
|
@ -85,9 +85,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
it 'updates existing session data' do
|
||||
updates = { status: 'processing', total_chunks: 5 }
|
||||
result = manager.update_session(updates)
|
||||
|
||||
|
||||
expect(result).to be true
|
||||
|
||||
|
||||
data = manager.get_session_data
|
||||
expect(data['status']).to eq('processing')
|
||||
expect(data['total_chunks']).to eq(5)
|
||||
|
|
@ -96,7 +96,7 @@ RSpec.describe Tracks::SessionManager do
|
|||
it 'returns false when session does not exist' do
|
||||
manager.cleanup_session
|
||||
result = manager.update_session({ status: 'processing' })
|
||||
|
||||
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
|
|
@ -104,9 +104,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
original_metadata = { mode: 'bulk' }
|
||||
manager.cleanup_session
|
||||
manager.create_session(original_metadata)
|
||||
|
||||
|
||||
manager.update_session({ status: 'processing' })
|
||||
|
||||
|
||||
data = manager.get_session_data
|
||||
expect(data['metadata']).to eq(original_metadata.stringify_keys)
|
||||
expect(data['status']).to eq('processing')
|
||||
|
|
@ -120,9 +120,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'marks session as processing with total chunks' do
|
||||
result = manager.mark_started(10)
|
||||
|
||||
|
||||
expect(result).to be true
|
||||
|
||||
|
||||
data = manager.get_session_data
|
||||
expect(data['status']).to eq('processing')
|
||||
expect(data['total_chunks']).to eq(10)
|
||||
|
|
@ -137,11 +137,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
end
|
||||
|
||||
it 'increments completed chunks counter' do
|
||||
expect {
|
||||
expect do
|
||||
manager.increment_completed_chunks
|
||||
}.to change {
|
||||
manager.get_session_data['completed_chunks']
|
||||
}.from(0).to(1)
|
||||
end.to change { manager.get_session_data['completed_chunks'] }.from(0).to(1)
|
||||
end
|
||||
|
||||
it 'returns false when session does not exist' do
|
||||
|
|
@ -156,23 +154,20 @@ RSpec.describe Tracks::SessionManager do
|
|||
end
|
||||
|
||||
it 'increments tracks created counter by 1 by default' do
|
||||
expect {
|
||||
expect do
|
||||
manager.increment_tracks_created
|
||||
}.to change {
|
||||
manager.get_session_data['tracks_created']
|
||||
}.from(0).to(1)
|
||||
end.to change { manager.get_session_data['tracks_created'] }.from(0).to(1)
|
||||
end
|
||||
|
||||
it 'increments tracks created counter by specified amount' do
|
||||
expect {
|
||||
expect do
|
||||
manager.increment_tracks_created(5)
|
||||
}.to change {
|
||||
manager.get_session_data['tracks_created']
|
||||
}.from(0).to(5)
|
||||
end.to change { manager.get_session_data['tracks_created'] }.from(0).to(5)
|
||||
end
|
||||
|
||||
it 'returns false when session does not exist' do
|
||||
manager.cleanup_session
|
||||
|
||||
expect(manager.increment_tracks_created).to be false
|
||||
end
|
||||
end
|
||||
|
|
@ -184,9 +179,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'marks session as completed with timestamp' do
|
||||
result = manager.mark_completed
|
||||
|
||||
|
||||
expect(result).to be true
|
||||
|
||||
|
||||
data = manager.get_session_data
|
||||
expect(data['status']).to eq('completed')
|
||||
expect(data['completed_at']).to be_present
|
||||
|
|
@ -200,11 +195,11 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'marks session as failed with error message and timestamp' do
|
||||
error_message = 'Something went wrong'
|
||||
|
||||
|
||||
result = manager.mark_failed(error_message)
|
||||
|
||||
|
||||
expect(result).to be true
|
||||
|
||||
|
||||
data = manager.get_session_data
|
||||
expect(data['status']).to eq('failed')
|
||||
expect(data['error_message']).to eq(error_message)
|
||||
|
|
@ -256,14 +251,14 @@ RSpec.describe Tracks::SessionManager do
|
|||
it 'calculates correct percentage' do
|
||||
manager.mark_started(4)
|
||||
2.times { manager.increment_completed_chunks }
|
||||
|
||||
|
||||
expect(manager.progress_percentage).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'rounds to 2 decimal places' do
|
||||
manager.mark_started(3)
|
||||
manager.increment_completed_chunks
|
||||
|
||||
|
||||
expect(manager.progress_percentage).to eq(33.33)
|
||||
end
|
||||
end
|
||||
|
|
@ -275,9 +270,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'removes session from cache' do
|
||||
expect(manager.session_exists?).to be true
|
||||
|
||||
|
||||
manager.cleanup_session
|
||||
|
||||
|
||||
expect(manager.session_exists?).to be false
|
||||
end
|
||||
end
|
||||
|
|
@ -287,11 +282,11 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'creates and returns a session manager' do
|
||||
result = described_class.create_for_user(user_id, metadata)
|
||||
|
||||
|
||||
expect(result).to be_a(described_class)
|
||||
expect(result.user_id).to eq(user_id)
|
||||
expect(result.session_exists?).to be true
|
||||
|
||||
|
||||
data = result.get_session_data
|
||||
expect(data['metadata']).to eq(metadata.deep_stringify_keys)
|
||||
end
|
||||
|
|
@ -305,9 +300,9 @@ RSpec.describe Tracks::SessionManager do
|
|||
|
||||
it 'returns session manager when session exists' do
|
||||
manager.create_session
|
||||
|
||||
|
||||
result = described_class.find_session(user_id, session_id)
|
||||
|
||||
|
||||
expect(result).to be_a(described_class)
|
||||
expect(result.user_id).to eq(user_id)
|
||||
expect(result.session_id).to eq(session_id)
|
||||
|
|
@ -324,16 +319,16 @@ RSpec.describe Tracks::SessionManager do
|
|||
it 'uses user-scoped cache keys' do
|
||||
expected_key = "track_generation:user:#{user_id}:session:#{session_id}"
|
||||
actual_key = manager.send(:cache_key)
|
||||
|
||||
|
||||
expect(actual_key).to eq(expected_key)
|
||||
end
|
||||
|
||||
it 'prevents cross-user session access' do
|
||||
manager.create_session
|
||||
other_manager = described_class.new(999, session_id)
|
||||
|
||||
|
||||
expect(manager.session_exists?).to be true
|
||||
expect(other_manager.session_exists?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Users::ExportData::Points, type: :service do
|
||||
before do
|
||||
allow_any_instance_of(Point).to receive(:trigger_incremental_track_generation)
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:service) { described_class.new(user) }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue