Harden vectorization task against per-package failures

A single failing package used to kill its whole worker thread silently
because only Vectorizer::Error was rescued per package. Now every
StandardError is caught and logged with class and backtrace, and the
embedding write is narrowed to update_column(:embedding) so the task
only ever issues a minimal UPDATE - it cannot touch any other column
or fire model callbacks. Progress stats no longer serialize database
writes through the mutex.
This commit is contained in:
Christoph Haas 2026-08-23 00:11:52 +02:00
parent 4d07f43a2e
commit 3550a4e2e2
2 changed files with 18 additions and 13 deletions

View file

@ -56,13 +56,12 @@ class Package < ApplicationRecord
# Compute and store the embedding vector for this package by asking # Compute and store the embedding vector for this package by asking
# the external vector service. Raises Vectorizer::Error on failure. # the external vector service. Raises Vectorizer::Error on failure.
# Returns true if the vector was saved. # Returns true if a vector was saved.
def update_embedding! def update_embedding!
text = embedding_text text = embedding_text
return false if text.blank? return false if text.blank?
self.embedding = Vectorizer.embed(text) update_column(:embedding, Vectorizer.embed(text))
save!
end end
# Return a relation of packages whose stored embeddings are closest to # Return a relation of packages whose stored embeddings are closest to

View file

@ -1,5 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# Long rake task bodies are normal; the block-length cop is meant for
# application code.
# rubocop:disable Metrics/BlockLength
namespace :debshots do namespace :debshots do
desc 'Compute embedding vectors for all packages based on their description ' \ desc 'Compute embedding vectors for all packages based on their description ' \
'and long description (missing ones only unless FORCE=1). ' \ 'and long description (missing ones only unless FORCE=1). ' \
@ -34,17 +37,20 @@ namespace :debshots do
stats = { done: 0, failed: 0 } stats = { done: 0, failed: 0 }
process_package = lambda do |package| process_package = lambda do |package|
begin
raise ArgumentError, 'no description text' if package.embedding_text.blank?
package.update_embedding!
stats_mutex.synchronize { stats[:done] += 1 }
rescue StandardError => e
# A single broken package must never take down a whole worker
# thread - log enough detail to diagnose it and move on.
stats_mutex.synchronize { stats[:failed] += 1 }
logger.error "Failed for package #{package.name}: #{e.class}: #{e.message}"
logger.error e.backtrace.first(3).join("\n") if e.backtrace
end
stats_mutex.synchronize do stats_mutex.synchronize do
begin
raise ArgumentError, 'no description text' if package.embedding_text.blank?
package.update_embedding!
stats[:done] += 1
rescue Vectorizer::Error, ArgumentError => e
stats[:failed] += 1
logger.error "Failed for package #{package.name}: #{e.message}"
end
processed = stats[:done] + stats[:failed] processed = stats[:done] + stats[:failed]
return unless (processed % 100).zero? return unless (processed % 100).zero?