Skip to content
Merged

1.10.2 #3199

Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
04e91a5
fix: quiet handled geocoder provider errors
Freika Jul 14, 2026
8756850
Retry reverse geocoding point write timeouts
Freika Jul 15, 2026
7ae4859
Retry transient OwnTracks point write timeouts
Freika Jul 17, 2026
dba153f
Prevent reverse-geocoding place deadlocks
Freika Jul 18, 2026
0ae855c
Prevent Null Island cleanup failures on legacy points
Freika Jul 20, 2026
bcf4982
Retry point write contention in the shared upsert path
Freika Jul 20, 2026
1f8b8bc
Move changelog entry to the Unreleased section
Freika Jul 20, 2026
c869059
Move changelog entry to the Unreleased section
Freika Jul 20, 2026
0e59ea5
Fold the Null Island changelog entry into a single line
Freika Jul 20, 2026
8977454
Cover lock-wait timeouts and retry exhaustion in reverse geocoding wr…
Freika Jul 20, 2026
2c40d7a
Merge pull request #3189 from Freika/fix/owntracks-write-timeout
Freika Jul 20, 2026
845a935
Merge branch 'dev' into fix/null-island-guards
Freika Jul 20, 2026
f8ab3f0
Merge pull request #3197 from Freika/fix/null-island-guards
Freika Jul 20, 2026
4a749a5
Merge pull request #3186 from Freika/fix/reverse-geocoding-lock-timeout
Freika Jul 20, 2026
eec1be0
Merge pull request #3183 from Freika/fix/reverse-geocoding-provider-e…
Freika Jul 20, 2026
1ec0814
Merge pull request #3193 from Freika/fix/place-bulk-write-deadlock
Freika Jul 20, 2026
93481cf
Stop the legacy lat/lon drop from crash-looping startup
Freika Jul 20, 2026
36d4e31
Only treat transient geocoder failures as handled provider errors
Freika Jul 20, 2026
43afcc6
Reuse the TLS helper and tighten the changelog wording
Freika Jul 20, 2026
9604b76
Merge branch 'dev' into fix/reverse-geocoding-provider-errors
Freika Jul 20, 2026
8416968
fix: delegate non-string DNS names to the original resolver
SAY-5 Jul 22, 2026
f35d7db
Fix legacy trial email retries
Freika Jul 25, 2026
5fff92f
Merge branch 'dev' into fix/legacy-trial-emails
Freika Jul 25, 2026
dfe9d2d
Merge pull request #3180 from Freika/fix/legacy-trial-emails
Freika Jul 25, 2026
59439e6
test: cover the DNS cache path and log the fix
Freika Jul 25, 2026
bf04182
Merge pull request #3205 from SAY-5/fix/dns-cache-nil-name
Freika Jul 25, 2026
f0256b5
Allow saving large-area posters
Freika Jul 25, 2026
a59c5e9
Add custom raster and style basemap URLs
Freika Jul 25, 2026
877e74e
Rescue QueryAborted and survive an unreachable queue
Freika Jul 25, 2026
5c5a424
Bound the drop by lock_timeout alone and retry quietly
Freika Jul 25, 2026
8a07b53
Bind the drop timeouts to the transaction that runs it
Freika Jul 27, 2026
d7bc525
fix: make visit suggestion consistent and predictable
Freika Jul 27, 2026
63d7684
feat: add a Poster Studio track width control
Freika Jul 27, 2026
b1a9f16
docs: reference the reported issues in the changelog entries
Freika Jul 27, 2026
c57ad0b
Merge branch 'dev' into fix/visits-consistency
Freika Jul 27, 2026
b15faf2
test: cover the track width clamp floor and negative guard
Freika Jul 27, 2026
66c0e7c
Shorten the lock wait and point the logs at the manual drop
Freika Jul 27, 2026
3f0b82e
Merge pull request #3238 from Freika/feat/poster-track-width
Freika Jul 27, 2026
e788640
Merge branch 'dev' into fix/poster-large-area-save
Freika Jul 27, 2026
6695be9
Wait long enough for the batched writers to let go
Freika Jul 27, 2026
5d35267
Merge pull request #3237 from Freika/fix/visits-consistency
Freika Jul 27, 2026
3f38965
fix: judge the poster area check against the rendered frame
Freika Jul 27, 2026
7fe2c4d
Merge branch 'dev' into fix/lat-lon-drop-lock-crashloop
Freika Jul 27, 2026
abea28d
Merge pull request #3198 from Freika/fix/lat-lon-drop-lock-crashloop
Freika Jul 27, 2026
3f38cad
Merge branch 'dev' into fix/poster-large-area-save
Freika Jul 27, 2026
d2f2bbd
Merge pull request #3230 from Freika/fix/poster-large-area-save
Freika Jul 27, 2026
36fcd3b
Update app version
Freika Jul 27, 2026
f91cacd
fix: keep custom basemaps loaded and restore layers after a style swap
Freika Jul 27, 2026
ffd8baf
Merge branch 'dev' into feature/custom-basemap-formats
Freika Jul 27, 2026
70597ac
Merge pull request #3231 from Freika/feature/custom-basemap-formats
Freika Jul 27, 2026
903d4b6
fix: lock place names customised before name locking existed
Freika Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

### Fixed

- Instances with heavy write traffic no longer crash-loop on the 1.10.1 upgrade. Dropping the legacy `points.latitude`/`points.longitude` columns needs an exclusive lock that busy instances could not win in one attempt, which aborted the migration and restarted the container in a loop. The drop is now retried, and if it still cannot get the lock it is handed to a background job so startup completes. If that job cannot get the lock either, the columns stay and the log prints the statement to run by hand — they are unused, so nothing breaks in the meantime (#3176)
- Trial lifecycle email jobs left over from older releases are now discarded instead of retrying forever in the background queue. Mail addressed to a record that has since been deleted is also discarded rather than retried.
- Reverse geocoding and place-name provider outages no longer flood error reporting with handled timeouts, dropped TLS connections, or invalid provider responses. A misconfigured or rate-limited provider — a bad API key, for example — is still reported.
- Reverse geocoding retries point updates that time out while waiting on concurrent writes.
- Google Semantic History and phone Timeline imports now tag points with a per-import tracker id instead of one shared constant, so tracks from different devices are no longer braided together. A one-time backfill rewrites existing points and regenerates affected tracks per user.
- Points at exactly (0,0) — a common GPS glitch — are no longer accepted from any ingestion path (API, OwnTracks, Overland, Traccar, file imports) and no longer produce suggested visits at "Null Island". Existing (0,0) points are flagged as anomalies by a one-time cleanup that also removes visits placed at (0,0) and recalculates affected stats and tracks.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- Place names you set yourself are no longer overwritten by nightly reverse geocoding. Renaming a place, creating one by hand, or picking one on the timeline locks its name; renaming it back to "Suggested place" hands it back to auto-naming. Map v2 and the place drawer show when a name is locked (#3086, #3175)
- Real-time visit detection no longer stops after the first run for users who track continuously — the debounce key is now released when the job runs.
- The nightly visit suggestion job no longer scans forward to the end of the calendar year; it processes only the day it was asked for.
- Merged visits now report the correct duration, centre, radius and suggested name instead of keeping the values of the first cluster in the merge.
- Visit suggestion failures no longer show a raw stack trace in your notifications, and repeated failures within an hour no longer create a notification each time (#3091)
- Points at exactly (0,0) — a common GPS glitch — are no longer accepted from any ingestion path (API, OwnTracks, Overland, Traccar, file imports) and no longer produce suggested visits at "Null Island". Existing (0,0) points are flagged as anomalies by a one-time cleanup that also removes visits placed at (0,0), tolerates legacy points without timestamps, and recalculates affected stats and tracks.
- Point uploads from all ingestion paths (REST API, OwnTracks, Overland, Traccar) now retry transient statement and lock-wait timeouts, not just deadlocks, instead of failing the upload.
- The DNS caching layer no longer crashes with a misleading `NoMethodError` when the SMTP server is not configured in the background worker, so email delivery surfaces the real configuration error instead. (#3038)

## [1.10.1] - 2026-07-19, Berlin

Expand Down Expand Up @@ -40,6 +52,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Track generation and user data recalculations now retry a bounded number of times when another job is already processing the same user's tracks, instead of dropping the request and reporting an error; a genuinely stuck lock is logged after the retries are exhausted. The per-user lock also renews itself while a job runs and frees within a minute if a worker dies, so a crashed job no longer blocks a user's track processing for up to half an hour.
- Dawarich added to an iOS Home Screen now opens Map v2 instead of an unrelated previously visited page (#3097)
- Point uploads (REST API, OwnTracks, Overland, Traccar) now write batches in a consistent order so concurrent uploads no longer deadlock each other, and both uploads and anomaly filtering recover automatically from any remaining transient database deadlocks instead of failing the upload or background job.
- Reverse geocoding overlapping places no longer exhausts retries because of concurrent database deadlocks.
- The app and Sidekiq containers no longer crash-loop on startup when `WEB_CONCURRENCY` or `BACKGROUND_PROCESSING_CONCURRENCY` reach the container as an unexpanded `${VAR:-default}` string (seen with some podman-compose versions); the entrypoint now warns and falls back to the default value (#3124)
- Cache preheating no longer times out for accounts with large location histories.
- Cloud: Changing plans resets the Lite archival-warning state, so a user downgraded to Lite again is notified about archived data again.
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/api/v1/places_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def show

def create
@place = current_api_user.places.build(place_params.except(:tag_ids))
@place.user_named = true

if @place.save
add_tags if tag_ids.present?
Expand Down Expand Up @@ -180,6 +181,7 @@ def serialize_place(place)
icon: place.tags.first&.icon,
color: place.tags.first&.color,
visits_count: place.visits.size,
name_locked: place.name_locked?,
created_at: place.created_at,
tags: place.tags.map do |tag|
{
Expand Down
1 change: 1 addition & 0 deletions app/controllers/places_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def show

def create
@place = current_user.places.build(place_params.except(:tag_ids))
@place.user_named = true

if @place.save
add_tags if tag_ids.present?
Expand Down
1 change: 1 addition & 0 deletions app/javascript/controllers/maps/maplibre/data_loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ export class DataLoader {
latitude: place.latitude,
longitude: place.longitude,
note: place.note,
nameLocked: Boolean(place.name_locked),
// Stringify tags for MapLibre GL JS compatibility
tags: JSON.stringify(place.tags || []),
// Use first tag's color if available
Expand Down
5 changes: 5 additions & 0 deletions app/javascript/controllers/maps/maplibre/event_handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ export class EventHandlers {
<div class="space-y-2">
${properties.tag ? `<div class="badge badge-sm badge-primary">${escapeHtml(properties.tag)}</div>` : ""}
${properties.description ? `<div>${escapeHtml(properties.description)}</div>` : ""}
${
properties.nameLocked
? `<div class="text-xs opacity-70" data-testid="place-name-lock">🔒 You named this place, so automatic naming won't change it. Rename it to "Suggested place" to hand it back.</div>`
: ""
}
</div>
`

Expand Down
4 changes: 3 additions & 1 deletion app/jobs/data_migrations/cleanup_null_island_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ def perform(user_id = nil)

rows = user.points.null_island.pluck(:id, :timestamp, :track_id)
track_ids = rows.filter_map(&:last).uniq
affected_months = rows.map do |_, timestamp, _|
affected_months = rows.filter_map do |_, timestamp, _|
next if timestamp.nil?

time = Time.zone.at(timestamp)
[time.year, time.month]
end.uniq
Expand Down
63 changes: 63 additions & 0 deletions app/jobs/data_migrations/drop_legacy_lat_lon_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

class DataMigrations::DropLegacyLatLonJob < ApplicationJob
queue_as :data_migrations

# Long enough to catch the gap between two batched writes. The same release
# backfills tracker ids and clears raw_data in 5k/10k-row batches that each
# hold RowExclusive on points for seconds at a time, so a one-second wait
# would expire inside a batch and lose every attempt. Each try does stall
# points behind an ACCESS EXCLUSIVE request, but at one try per five minutes
# that is a fraction of a percent of the time.
LOCK_TIMEOUT = '5s'

MAX_ATTEMPTS = 288

# Losing the lock race is the expected case on a busy instance, so back off and
# try again over the next day rather than reporting a failure. Attempts are
# capped: each one stalls points writes for LOCK_TIMEOUT, so a drop that can
# never win must stop and say so instead of retrying invisibly forever.
# QueryAborted covers both LockWaitTimeout's sibling StatementTimeout and the
# QueryCanceled that PostgreSQL raises when statement_timeout fires.
retry_on ActiveRecord::LockWaitTimeout, wait: 5.minutes, attempts: MAX_ATTEMPTS do |_job, error|
log_exhaustion(error)
end

retry_on ActiveRecord::QueryAborted, wait: 5.minutes, attempts: MAX_ATTEMPTS do |_job, error|
log_exhaustion(error)
end

def self.log_exhaustion(error)
Rails.logger.error(
"[DataMigrations::DropLegacyLatLon] gave up after #{MAX_ATTEMPTS} attempts (#{error.class}: #{error.message}); " \
'points.latitude / points.longitude are still present. Drop them once traffic is quiet with: ' \
"BEGIN; SET LOCAL lock_timeout = '#{LOCK_TIMEOUT}'; " \
'ALTER TABLE points DROP COLUMN IF EXISTS latitude, DROP COLUMN IF EXISTS longitude; COMMIT;'
)
end

def perform
connection = ActiveRecord::Base.connection
return unless legacy_columns?(connection)

# SET LOCAL binds both timeouts to this transaction's backend so they survive
# PgBouncer transaction pooling — a bare SET + ALTER can otherwise land on
# different servers, leaving the drop with no timeout at all and an unbounded
# ACCESS EXCLUSIVE request queued ahead of every points read and write.
# statement_timeout is pinned off so only lock_timeout bounds the wait; the
# drop itself is metadata-only once the lock is held.
connection.transaction do
connection.execute('SET LOCAL statement_timeout = 0')
connection.execute("SET LOCAL lock_timeout = '#{LOCK_TIMEOUT}'")
connection.execute('ALTER TABLE points DROP COLUMN IF EXISTS latitude, DROP COLUMN IF EXISTS longitude')
end

Rails.logger.info('[DataMigrations::DropLegacyLatLon] dropped legacy points.latitude / points.longitude')
end

private

def legacy_columns?(connection)
connection.column_exists?(:points, :latitude) || connection.column_exists?(:points, :longitude)
end
end
14 changes: 14 additions & 0 deletions app/jobs/users/mailer_sending_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,23 @@ class UnknownEmailType < StandardError; end
'account_destroy_confirmation' => ['UsersMailer', :account_destroy_confirmation]
}.freeze

LEGACY_MANAGER_EMAIL_TYPES = %w[
trial_expired
trial_expires_soon
post_trial_reminder_early
post_trial_reminder_late
].freeze

def perform(user_id, email_type, **options)
user = find_user_or_skip(user_id) || return

if LEGACY_MANAGER_EMAIL_TYPES.include?(email_type.to_s)
Rails.logger.info(
"[Users::MailerSendingJob] skipping legacy Manager-owned email_type=#{email_type} user_id=#{user.id}"
)
return
end

mailer_class_name, action = MAILER_REGISTRY.fetch(email_type.to_s) do
raise UnknownEmailType, "Unknown email_type=#{email_type.inspect} user_id=#{user.id}"
end
Expand Down
9 changes: 9 additions & 0 deletions app/jobs/visit_suggesting_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class VisitSuggestingJob < ApplicationJob

# Passing timespan of more than 3 years somehow results in duplicated Places
def perform(user_id:, start_at:, end_at:)
release_debounce_key(user_id)

user = find_user_or_skip(user_id) || return

return unless user.safe_settings.visits_suggestions_enabled?
Expand All @@ -28,6 +30,13 @@ def perform(user_id:, start_at:, end_at:)

private

# retry: false means a transient Redis blip here would otherwise drop the whole run.
def release_debounce_key(user_id)
Visits::RealtimeDebouncer.new(user_id).clear
rescue StandardError => e
Rails.logger.warn("[VisitSuggestingJob] debounce key release failed user_id=#{user_id}: #{e.class}: #{e.message}")
end

def parse_date(date)
date.is_a?(String) ? Time.zone.parse(date) : date.to_datetime
end
Expand Down
4 changes: 4 additions & 0 deletions app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV['SMTP_FROM']
layout 'mailer'

rescue_from ActiveJob::DeserializationError do |exception|
Rails.logger.info("[ApplicationMailer] discarding delivery for a missing record: #{exception.message}")
end
end
8 changes: 8 additions & 0 deletions app/mailers/users_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,12 @@ def otp_account_locked

mail(to: @user.email, subject: 'Dawarich account temporarily locked')
end

def trial_expired; end

def trial_expires_soon; end

def post_trial_reminder_early; end

def post_trial_reminder_late; end
end
11 changes: 8 additions & 3 deletions app/models/concerns/archivable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ module Archivable
UPSERT_MAX_RETRIES = 3
UPSERT_BACKOFF_BASE = 0.1
UPSERT_BACKOFF_JITTER = 0.05
UPSERT_CONTENTION_ERRORS = [
ActiveRecord::Deadlocked,
ActiveRecord::LockWaitTimeout,
ActiveRecord::QueryCanceled
].freeze

class_methods do
# Bulk-ingest counterpart of the reset_archival_on_raw_data_change
Expand All @@ -40,7 +45,7 @@ def archival_safe_upsert_all(rows, returning:)
set_clauses << '"updated_at" = CURRENT_TIMESTAMP' unless update_columns.include?(:updated_at)
set_clauses.concat(archival_reset_clauses) if update_columns.include?(:raw_data)

with_deadlock_retry do
with_write_contention_retry do
upsert_all(
rows,
unique_by: UPSERT_CONFLICT_KEYS,
Expand All @@ -52,12 +57,12 @@ def archival_safe_upsert_all(rows, returning:)

private

def with_deadlock_retry
def with_write_contention_retry
retries = 0

begin
yield
rescue ActiveRecord::Deadlocked => e
rescue *UPSERT_CONTENTION_ERRORS => e
retries += 1
raise e if retries > UPSERT_MAX_RETRIES

Expand Down
17 changes: 17 additions & 0 deletions app/models/place.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ class Place < ApplicationRecord
has_many :place_visits, dependent: :destroy
has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit

attr_accessor :machine_named, :user_named

before_validation :build_lonlat, if: -> { latitude.present? && longitude.present? }
before_save :lock_name_on_user_edit

validates :name, presence: true, length: { maximum: 255 }
validates :lonlat, presence: true
Expand All @@ -39,6 +42,10 @@ def lat
lonlat.y
end

def name_locked?
name_locked_at.present?
end

def osm_id
geodata.dig('properties', 'osm_id')
end
Expand All @@ -60,4 +67,14 @@ def osm_type
def build_lonlat
self.lonlat = "POINT(#{longitude} #{latitude})"
end

def lock_name_on_user_edit
return if machine_named
return unless will_save_change_to_name?

return self.name_locked_at = nil if name == DEFAULT_NAME
return if new_record? && !user_named

self.name_locked_at = Time.current
Comment on lines +71 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mark machine-originated name changes explicitly.

This callback treats every unmarked name change as a user edit. However, ReverseGeocoding::Places::FetchData#populate_place_attributes assigns place.name without setting machine_named, so its machine-generated name will be persisted with name_locked_at. Later geocoding will incorrectly preserve that stale name. Mark that path, and every other machine naming path, before saving.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/place.rb` around lines 71 - 78, Update
ReverseGeocoding::Places::FetchData#populate_place_attributes and every other
machine-generated naming path to set machine_named before assigning or saving
the generated place name. Ensure lock_name_on_user_edit recognizes these changes
as machine-originated so they do not persist name_locked_at, while preserving
user-edit behavior for unmarked name changes.

end
end
3 changes: 2 additions & 1 deletion app/serializers/api/place_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def call
geodata: place.geodata,
created_at: place.created_at,
updated_at: place.updated_at,
reverse_geocoded_at: place.reverse_geocoded_at
reverse_geocoded_at: place.reverse_geocoded_at,
name_locked: place.name_locked?
}
end

Expand Down
19 changes: 15 additions & 4 deletions app/services/places/name_fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,28 @@ def call
name = ::Visits::Names::Builder.build_from_properties(properties)

ActiveRecord::Base.transaction do
place.name = name if name.present?
place.machine_named = true
place.name = name if name.present? && !place.name_locked?
place.city = properties['city'] if properties['city'].present?
place.country = properties['country'] if properties['country'].present?
place.geodata = result.data if DawarichSettings.store_geodata?
place.save!
place.visits.where(name: Place::DEFAULT_NAME).update_all(name: name) if name.present?

propagated_name = place.name
place.visits.where(name: Place::DEFAULT_NAME).update_all(name: propagated_name) if propagated_name.present?

place
end
rescue *ReverseGeocoding::ProviderErrors::TRANSIENT => e
Rails.logger.warn("Geocoding provider error in NameFetcher for place #{place.id}: #{e.message}")
nil
rescue StandardError => e
Rails.logger.error("Geocoding error in NameFetcher for place #{place.id}: #{e.message}")
ExceptionReporter.call(e)
if ReverseGeocoding::ProviderErrors.transient_tls?(e)
Rails.logger.warn("Geocoding provider error in NameFetcher for place #{place.id}: #{e.message}")
else
Rails.logger.error("Geocoding error in NameFetcher for place #{place.id}: #{e.message}")
ExceptionReporter.call(e)
end
nil
end

Expand Down
13 changes: 8 additions & 5 deletions app/services/reverse_geocoding/places/fetch_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@

data = normalize_geocoder_data(reverse_geocoded_place.data)

place.update!(
name: place_name(data),
attributes = {
lonlat: build_point_coordinates(data['geometry']['coordinates']),
city: data['properties']['city'],
country: data['properties']['country'],
geodata: data,
source: Place.sources[:photon],
reverse_geocoded_at: Time.current
)
}
attributes[:name] = place_name(data) unless place.name_locked?

place.machine_named = true
place.update!(attributes)
Comment on lines +39 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline app/services/reverse_geocoding/places/fetch_data.rb || true

echo "== relevant source =="
sed -n '1,220p' app/services/reverse_geocoding/places/fetch_data.rb | cat -n

echo "== git diff stat =="
git diff --stat || true

echo "== searches for helper/usages =="
rg -n "with_deadlock_retry|Deadlocked|Deadlock|lock_wait|query_cancellation|upsert_all|machine_named" app/services/reverse_geocoding -S || true

Repository: Freika/dawarich

Length of output: 9138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== reverse geocoding points helper =="
sed -n '1,120p' app/services/reverse_geocoding/points/fetch_data.rb | cat -n

echo "== model machine_named references =="
rg -n "machine_named|name_locked" app -S || true

echo "== ActiveRecord retry exceptions definitions usages =="
rg -n "ActiveRecord::ConnectionTimedOut|QueryCanceled|Deadlock|deadlock|query_cancellation|lock_wait_timeout" rails Gemfile . -S || true

Repository: Freika/dawarich

Length of output: 10493


🌐 Web query:

Rails 8.1.3 ActiveRecord::ConnectionTimedOut ActiveRecord::QueryCanceled ActiveRecord::Deadlocked retry_exceptions

💡 Result:

In Rails 8.1, ActiveRecord provides built-in mechanisms for handling retries, but they are focused on idempotent queries encountering specific connection errors rather than a general-purpose "retry_exceptions" configuration for all database exceptions [1][2][3]. Key distinctions for the exceptions mentioned: 1. ActiveRecord::ConnectionTimeoutError: This exception is raised when a connection cannot be obtained from the pool within the allowed timeout period [4]. It is not automatically retried by the adapter's connection retry mechanism because it indicates pool exhaustion rather than a transient connection failure [5][6]. 2. ActiveRecord::Deadlocked: This exception occurs when the database engine kills a transaction to resolve a deadlock [7]. Rails does not automatically retry deadlocked transactions at the adapter level because retrying them requires restarting the entire transaction block, which is not inherently safe to do automatically [7]. For deadlocks, the standard Rails pattern is to use ActiveJob's retry mechanism or manual rescue blocks [8]. For example: retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3 [8] 3. ActiveRecord::QueryCanceled: This generally relates to query timeouts or external cancellations. It is not part of the automatic connection-level retry logic [5][6]. Automatic Retry Logic Rails 8.1 automatically retries certain idempotent queries (such as simple association loads and #exists? checks) only when it encounters specific connection-related errors (e.g., lost connections) [1][2]. This behavior is governed by the database configuration in database.yml [3]: - connection_retries: Defaults to 1. Set to 0 to disable [6][3]. - retry_deadline: A duration (in seconds) after which even retryable queries will stop being retried [6][3]. There is no "retry_exceptions" configuration key in ActiveRecord; rather, the adapter internally defines which specific connection errors are considered retryable [5][6]. If you need to retry higher-level application errors or transactional issues like Deadlocked, you must implement that logic at the application level (e.g., using a retry library or ActiveJob) [8].

Citations:


Apply the bounded contention-retry policy to the primary place update.

update_place calls place.update!(attributes) without with_deadlock_retry, so Place#save_places and Place#upsert_all have built-in contention handling while contention on the original place stays immediate.

Proposed fix
-    place.update!(attributes)
+    with_deadlock_retry { place.update!(attributes) }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
attributes = {
lonlat: build_point_coordinates(data['geometry']['coordinates']),
city: data['properties']['city'],
country: data['properties']['country'],
geodata: data,
source: Place.sources[:photon],
reverse_geocoded_at: Time.current
)
}
attributes[:name] = place_name(data) unless place.name_locked?
place.machine_named = true
place.update!(attributes)
attributes = {
lonlat: build_point_coordinates(data['geometry']['coordinates']),
city: data['properties']['city'],
country: data['properties']['country'],
geodata: data,
source: Place.sources[:photon],
reverse_geocoded_at: Time.current
}
attributes[:name] = place_name(data) unless place.name_locked?
place.machine_named = true
with_deadlock_retry { place.update!(attributes) }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/reverse_geocoding/places/fetch_data.rb` around lines 39 - 50,
Wrap the primary place update in update_place with the existing bounded
with_deadlock_retry helper, ensuring place.machine_named assignment and
place.update!(attributes) execute within the retry block. Preserve the current
attributes and update behavior while applying the same contention policy used by
Place#save_places and Place#upsert_all.

end

def find_place(place_data, existing_places)
Expand Down Expand Up @@ -107,7 +110,7 @@
end

def populate_place_attributes(place, data)
place.name = place_name(data)
place.name = place_name(data) unless place.name_locked?
place.city = data['properties']['city']
place.country = data['properties']['country']
place.geodata = data
Expand Down Expand Up @@ -142,7 +145,7 @@

return unless places_to_update.any?

update_attributes = places_to_update.uniq(&:id).map do |place|
update_attributes = places_to_update.uniq(&:id).sort_by(&:id).map do |place|
{
id: place.id,
name: place.name,
Expand Down
Loading
Loading