cohere-transcribe 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +9 -7
  4. data/ext/cohere_transcribe_native/.gitignore +6 -0
  5. data/ext/cohere_transcribe_native/CMakeLists.txt +8 -0
  6. data/ext/cohere_transcribe_native/audio_abi.cpp +20 -0
  7. data/ext/cohere_transcribe_native/audio_exports.macos +1 -0
  8. data/ext/cohere_transcribe_native/audio_exports.map +1 -0
  9. data/ext/cohere_transcribe_native/test/abi_smoke.rb +26 -0
  10. data/lib/cohere/transcribe/alignment/aligner.rb +40 -7
  11. data/lib/cohere/transcribe/asr/native.rb +43 -97
  12. data/lib/cohere/transcribe/audio/decoder.rb +184 -75
  13. data/lib/cohere/transcribe/audio/ffmpeg_native.rb +84 -32
  14. data/lib/cohere/transcribe/audio/segmentation.rb +1 -0
  15. data/lib/cohere/transcribe/cli.rb +30 -22
  16. data/lib/cohere/transcribe/configuration.rb +13 -0
  17. data/lib/cohere/transcribe/constants.rb +1 -1
  18. data/lib/cohere/transcribe/doctor.rb +2 -1
  19. data/lib/cohere/transcribe/hub.rb +446 -58
  20. data/lib/cohere/transcribe/input.rb +18 -3
  21. data/lib/cohere/transcribe/internal/interruptible_native_call.rb +70 -0
  22. data/lib/cohere/transcribe/internal/session_ownership.rb +61 -0
  23. data/lib/cohere/transcribe/internal/utf8.rb +19 -0
  24. data/lib/cohere/transcribe/model_identity.rb +10 -1
  25. data/lib/cohere/transcribe/output/publication.rb +95 -86
  26. data/lib/cohere/transcribe/pytorch_checkpoint.rb +38 -22
  27. data/lib/cohere/transcribe/runtime/engine.rb +37 -16
  28. data/lib/cohere/transcribe/runtime/preparation.rb +212 -35
  29. data/lib/cohere/transcribe/runtime/resources.rb +28 -62
  30. data/lib/cohere/transcribe/state/checkpoint.rb +3 -2
  31. data/lib/cohere/transcribe/state/io.rb +217 -117
  32. data/lib/cohere/transcribe/state/locking.rb +383 -64
  33. data/lib/cohere/transcribe/state/manifest.rb +21 -22
  34. data/lib/cohere/transcribe/types.rb +25 -11
  35. data/lib/cohere/transcribe/version.rb +1 -1
  36. data/sig/cohere/transcribe.rbs +1 -0
  37. metadata +5 -1
@@ -264,7 +264,13 @@ module Cohere
264
264
  requested_workers: resolved_options.preprocess_workers,
265
265
  enabled: resolved_options.pipeline_preparation,
266
266
  worker_limit: vad_file_concurrency_limit(resolved_options),
267
- estimate_bytes: estimate_bytes
267
+ estimate_bytes: estimate_bytes,
268
+ exclusive_retry: lambda do |prepared|
269
+ prepared.error.is_a?(Audio::DecodedAudioLimitError)
270
+ end,
271
+ retained_bytes: lambda do |prepared|
272
+ prepared.decoded ? prepared.decoded.samples.byte_size : 0
273
+ end
268
274
  ) do |item, decoded_byte_limit, worker_slot|
269
275
  prepare_entry(
270
276
  item,
@@ -371,7 +377,12 @@ module Cohere
371
377
  end
372
378
 
373
379
  def preflight_outcome(index, entry, plan, resolved_options, measurements)
374
- Output::Publication.with_plan_lock(plan) do
380
+ if resolved_options.publication&.existing == "skip" && plan.skipped
381
+ optimistic = Output::Publication.revalidate(plan, resolved_options)
382
+ return skipped_result(entry, plan) if optimistic.action == :skip
383
+ end
384
+
385
+ Output::Publication.with_plan_lock(plan) do |lock|
375
386
  decision = Output::Publication.revalidate(plan, resolved_options)
376
387
  case decision.action
377
388
  when :skip
@@ -383,7 +394,8 @@ module Cohere
383
394
  plan,
384
395
  decision,
385
396
  resolved_options,
386
- measurements
397
+ measurements,
398
+ lock
387
399
  )
388
400
  else
389
401
  PreparationItem.new(index: index, entry: entry, plan: plan)
@@ -391,7 +403,7 @@ module Cohere
391
403
  end
392
404
  end
393
405
 
394
- def preflight_resumed_entry(index, entry, plan, decision, resolved_options, measurements)
406
+ def preflight_resumed_entry(index, entry, plan, decision, resolved_options, measurements, lock)
395
407
  prepared = Preparation::PreparedEntry.new(
396
408
  item: PreparationItem.new(index: index, entry: entry, plan: plan),
397
409
  snapshot: source_snapshot(entry.path),
@@ -410,6 +422,7 @@ module Cohere
410
422
  decision.generation_id,
411
423
  resolved_options,
412
424
  measurements,
425
+ lock: lock,
413
426
  defer_word_alignment: resolved_options.alignment == "word"
414
427
  )
415
428
  rescue FatalRuntimeError, ProgressCallbackError
@@ -569,7 +582,7 @@ module Cohere
569
582
  measurements.decode_seconds += prepared.decode_seconds
570
583
  measurements.vad_seconds += prepared.vad_seconds
571
584
  record_vad_measurements(measurements, prepared.vad_details)
572
- Output::Publication.with_plan_lock(plan) do
585
+ Output::Publication.with_plan_lock(plan) do |lock|
573
586
  decision = Output::Publication.revalidate(plan, resolved_options)
574
587
  return skipped_result(entry, plan) if decision.action == :skip
575
588
 
@@ -587,11 +600,12 @@ module Cohere
587
600
  decision.checkpoint,
588
601
  decision.generation_id,
589
602
  resolved_options,
590
- measurements
603
+ measurements,
604
+ lock: lock
591
605
  )
592
606
  end
593
607
 
594
- process_fresh_entry(prepared, resolved_options, measurements)
608
+ process_fresh_entry(prepared, resolved_options, measurements, lock: lock)
595
609
  end
596
610
  rescue FatalRuntimeError, ProgressCallbackError
597
611
  raise
@@ -609,7 +623,7 @@ module Cohere
609
623
  measurements.decode_seconds += prepared.decode_seconds
610
624
  measurements.vad_seconds += prepared.vad_seconds
611
625
  record_vad_measurements(measurements, prepared.vad_details)
612
- outcome = Output::Publication.with_plan_lock(plan) do
626
+ outcome = Output::Publication.with_plan_lock(plan) do |lock|
613
627
  decision = Output::Publication.revalidate(plan, resolved_options)
614
628
  if decision.action == :skip
615
629
  WordPipeline::Final.new(index: item.index, result: skipped_result(entry, plan))
@@ -635,6 +649,7 @@ module Cohere
635
649
  decision.generation_id,
636
650
  resolved_options,
637
651
  measurements,
652
+ lock: lock,
638
653
  defer_word_alignment: true
639
654
  )
640
655
  else
@@ -642,6 +657,7 @@ module Cohere
642
657
  prepared,
643
658
  resolved_options,
644
659
  measurements,
660
+ lock: lock,
645
661
  defer_word_alignment: true
646
662
  )
647
663
  end
@@ -661,7 +677,7 @@ module Cohere
661
677
  def process_word_alignment(work, reload_result, resolved_options, measurements, alignment_state)
662
678
  entry = work.entry
663
679
  plan = work.plan
664
- Output::Publication.with_plan_lock(plan) do
680
+ Output::Publication.with_plan_lock(plan) do |lock|
665
681
  decision = Output::Publication.revalidate(plan, resolved_options)
666
682
  return skipped_result(entry, plan) if decision.action == :skip
667
683
  if (alignment_error = alignment_state.fetch(:error))
@@ -679,6 +695,7 @@ module Cohere
679
695
  resolved_options,
680
696
  measurements,
681
697
  work.generation_id,
698
+ lock: lock,
682
699
  asr_evicted: true
683
700
  )
684
701
  ensure_source_unchanged!(entry.path, work.source_snapshot)
@@ -710,7 +727,7 @@ module Cohere
710
727
  record_alignment_measurements(measurements, before, aligner) if before && aligner
711
728
  end
712
729
 
713
- def process_fresh_entry(prepared, resolved_options, measurements, defer_word_alignment: false)
730
+ def process_fresh_entry(prepared, resolved_options, measurements, lock:, defer_word_alignment: false)
714
731
  item = prepared.item
715
732
  entry = item.entry
716
733
  plan = item.plan
@@ -787,7 +804,8 @@ module Cohere
787
804
  speech_spans: speech_spans,
788
805
  vad_provider_options: vad_provider_options(vad_details),
789
806
  directory_binding: plan.directory_bindings.last,
790
- guard_bindings: plan.directory_bindings
807
+ guard_bindings: plan.directory_bindings,
808
+ lock: lock
791
809
  )
792
810
  ensure
793
811
  measurements.checkpoint_seconds += monotonic - checkpoint_started
@@ -812,12 +830,13 @@ module Cohere
812
830
  plan,
813
831
  resolved_options,
814
832
  measurements,
815
- generation_id
833
+ generation_id,
834
+ lock: lock
816
835
  )
817
836
  end
818
837
 
819
838
  def process_resumed_entry(prepared, checkpoint, generation_id, resolved_options, measurements,
820
- defer_word_alignment: false)
839
+ lock:, defer_word_alignment: false)
821
840
  item = prepared.item
822
841
  entry = item.entry
823
842
  plan = item.plan
@@ -866,7 +885,8 @@ module Cohere
866
885
  plan,
867
886
  resolved_options,
868
887
  measurements,
869
- generation_id
888
+ generation_id,
889
+ lock: lock
870
890
  )
871
891
  end
872
892
 
@@ -888,7 +908,7 @@ module Cohere
888
908
  end
889
909
 
890
910
  def render_and_publish(result, samples, segment_times, speech_spans, plan, resolved_options,
891
- measurements, generation_id, asr_evicted: false)
911
+ measurements, generation_id, lock:, asr_evicted: false)
892
912
  progressive_started = monotonic unless resolved_options.alignment == "word"
893
913
  words, fallback_alignment_segments = words_for_segments(
894
914
  samples,
@@ -922,7 +942,8 @@ module Cohere
922
942
  result,
923
943
  resolved_options,
924
944
  generation_id: generation_id,
925
- speech_spans: speech_spans
945
+ speech_spans: speech_spans,
946
+ lock: lock
926
947
  )
927
948
  if outputs.any?
928
949
  result = result.with(
@@ -31,15 +31,85 @@ module Cohere
31
31
  #
32
32
  # A pipelined group receives at most half of the configured decoded-PCM
33
33
  # budget (and never more than 512 MiB). Estimated decode sizes determine
34
- # group membership and per-file ceilings. A file that cannot fit that cap
35
- # is prepared alone with the full configured ceiling and without overlap.
34
+ # group membership and per-file ceilings. Unknown sizes receive an equal
35
+ # share initially. A ceiling failure first retries within the remaining
36
+ # configured PCM budget while successful group entries stay retained. If
37
+ # that is insufficient, later retained audio is released and only those
38
+ # entries are prepared again after the full-ceiling retry. A known file
39
+ # that cannot fit the group cap starts with that exclusive path.
36
40
  # Native decoder implementation transients are outside this retained-PCM
37
41
  # accounting, as they are in the Python path.
38
42
  class Pipeline
39
43
  include Enumerable
40
44
 
41
- Group = Data.define(:items, :limits, :exclusive)
42
- private_constant :Group
45
+ Group = Data.define(:items, :limits, :exclusive) do
46
+ def self.exclusive(item, limit)
47
+ new(items: [item].freeze, limits: [limit].freeze, exclusive: true)
48
+ end
49
+ end
50
+
51
+ # Explicit cursor keeps metadata probes on the preparation caller's
52
+ # native stack while preserving lazy current/next-group discovery.
53
+ class GroupCursor
54
+ def initialize(items:, workers:, group_byte_limit:, memory_byte_limit:,
55
+ estimated_reservation:, unknown_reservation:, bounded_group:)
56
+ @items = items
57
+ @workers = workers
58
+ @group_byte_limit = group_byte_limit
59
+ @memory_byte_limit = memory_byte_limit
60
+ @estimated_reservation = estimated_reservation
61
+ @unknown_reservation = unknown_reservation
62
+ @bounded_group = bounded_group
63
+ @index = 0
64
+ @pending = nil
65
+ end
66
+
67
+ def next
68
+ items = []
69
+ estimates = []
70
+ total = 0
71
+ while items.length < @workers
72
+ pair = take
73
+ break unless pair
74
+
75
+ item, estimate = pair
76
+ if estimate > @group_byte_limit
77
+ return Group.exclusive(item, @memory_byte_limit) if items.empty?
78
+
79
+ @pending = pair
80
+ break
81
+ end
82
+ if !items.empty? && total + estimate > @group_byte_limit
83
+ @pending = pair
84
+ break
85
+ end
86
+
87
+ items << item
88
+ estimates << estimate
89
+ total += estimate
90
+ end
91
+ raise StopIteration if items.empty?
92
+
93
+ @bounded_group.call(items, estimates)
94
+ end
95
+
96
+ private
97
+
98
+ def take
99
+ if @pending
100
+ pair = @pending
101
+ @pending = nil
102
+ return pair
103
+ end
104
+ return if @index >= @items.length
105
+
106
+ item = @items.fetch(@index)
107
+ @index += 1
108
+ estimate = @estimated_reservation.call(item) || @unknown_reservation
109
+ [item, estimate]
110
+ end
111
+ end
112
+ private_constant :Group, :GroupCursor
43
113
 
44
114
  class WorkerPool
45
115
  STOP = Object.new.freeze
@@ -94,7 +164,7 @@ module Cohere
94
164
  attr_reader :effective_workers, :group_byte_limit, :memory_byte_limit, :wait_seconds
95
165
 
96
166
  def initialize(items, memory_byte_limit:, requested_workers:, enabled:, worker_limit: nil,
97
- estimate_bytes: nil, &prepare)
167
+ estimate_bytes: nil, exclusive_retry: nil, retained_bytes: nil, &prepare)
98
168
  raise ArgumentError, "prepare block is required" unless prepare
99
169
 
100
170
  @items = items.to_a.freeze
@@ -103,6 +173,8 @@ module Cohere
103
173
 
104
174
  @prepare = prepare
105
175
  @estimate_bytes = estimate_bytes
176
+ @exclusive_retry = exclusive_retry
177
+ @retained_bytes = retained_bytes
106
178
  @wait_seconds = 0.0
107
179
  @enabled = enabled && @items.length > 1
108
180
  @worker_limit = worker_limit.nil? ? nil : Integer(worker_limit)
@@ -131,14 +203,24 @@ module Cohere
131
203
 
132
204
  groups = build_groups
133
205
  pool = WorkerPool.new(@effective_workers, &@prepare)
134
- pending = submit(groups.first, 0, pool)
135
- groups.each_with_index do |group, group_index|
206
+ group = groups.next
207
+ group_index = 0
208
+ pending = submit(group, group_index, pool)
209
+ loop do
136
210
  prepared = resolve(pending)
137
211
  pending = nil
138
- next_group = groups[group_index + 1]
139
- pending = submit(next_group, group_index + 1, pool) if next_group && !group.exclusive && !next_group.exclusive
140
- consume(prepared, &block)
141
- pending ||= submit(next_group, group_index + 1, pool) if next_group
212
+ next_group = next_group(groups)
213
+ if retry_exclusively?(group, prepared)
214
+ consume_with_exclusive_retry(group, prepared, pool, &block)
215
+ else
216
+ pending = submit(next_group, group_index + 1, pool) if next_group && !group.exclusive && !next_group.exclusive
217
+ consume(prepared, &block)
218
+ end
219
+ break unless next_group
220
+
221
+ pending ||= submit(next_group, group_index + 1, pool)
222
+ group = next_group
223
+ group_index += 1
142
224
  end
143
225
  completed = true
144
226
  ensure
@@ -176,7 +258,7 @@ module Cohere
176
258
  def submit(group, group_index, pool)
177
259
  Thread.new do
178
260
  Thread.current.name = "cohere-audio-group-#{group_index}" if Thread.current.respond_to?(:name=)
179
- prepare_group(group, pool)
261
+ pool.prepare(group)
180
262
  end.tap { |thread| thread.report_on_exception = false }
181
263
  end
182
264
 
@@ -187,36 +269,119 @@ module Cohere
187
269
  @wait_seconds += monotonic - started if started
188
270
  end
189
271
 
190
- def prepare_group(group, pool)
191
- pool.prepare(group)
272
+ def next_group(groups)
273
+ groups.next
274
+ rescue StopIteration
275
+ nil
192
276
  end
193
277
 
194
- def build_groups
195
- return equal_groups unless @estimate_bytes
196
-
197
- groups = []
198
- items = []
199
- estimates = []
200
- @items.each do |item|
201
- estimate = estimated_reservation(item)
202
- if estimate.nil? || estimate > @group_byte_limit
203
- groups << bounded_group(items, estimates) unless items.empty?
204
- groups << Group.new(items: [item].freeze, limits: [@memory_byte_limit].freeze, exclusive: true)
205
- items = []
206
- estimates = []
278
+ def retry_exclusively?(group, prepared)
279
+ return false if group.exclusive || !@exclusive_retry
280
+
281
+ prepared.any? { |entry| @exclusive_retry.call(entry) }
282
+ end
283
+
284
+ def consume_with_exclusive_retry(group, prepared, pool, &block)
285
+ prepared.each_index do |index|
286
+ entry = prepared.fetch(index)
287
+ if entry.nil?
288
+ consume_single_retry(group.items.fetch(index), @memory_byte_limit, pool, &block)
207
289
  next
208
290
  end
291
+ unless @exclusive_retry.call(entry)
292
+ begin
293
+ block.call(entry)
294
+ ensure
295
+ prepared[index] = nil
296
+ end
297
+ next
298
+ end
299
+
300
+ prepared[index] = nil
301
+ failed_limit = group.limits.fetch(index)
302
+ if failed_limit >= @memory_byte_limit
303
+ block.call(entry)
304
+ next
305
+ end
306
+ unless @retained_bytes
307
+ release_retained_entries(prepared)
308
+ collect_released_audio
309
+ end
310
+ limit = available_retry_limit(prepared)
311
+ retried = prepare_single(group.items.fetch(index), limit, pool) if limit > failed_limit
312
+ full_retry = retried.nil? ||
313
+ (limit < @memory_byte_limit && @exclusive_retry.call(retried.fetch(0)))
314
+ if full_retry
315
+ retried&.clear
316
+ release_retained_entries(prepared)
317
+ collect_released_audio
318
+ retried = prepare_single(group.items.fetch(index), @memory_byte_limit, pool)
319
+ end
320
+ consume_single_result(retried, &block)
321
+ end
322
+ ensure
323
+ release(prepared)
324
+ end
325
+
326
+ def consume_single_retry(item, limit, pool, &)
327
+ consume_single_result(prepare_single(item, limit, pool), &)
328
+ end
329
+
330
+ def prepare_single(item, limit, pool)
331
+ pool.prepare(Group.exclusive(item, limit))
332
+ end
333
+
334
+ def consume_single_result(retained)
335
+ yield retained.fetch(0)
336
+ ensure
337
+ retained&.clear
338
+ collect_released_audio
339
+ end
340
+
341
+ def available_retry_limit(prepared)
342
+ return @memory_byte_limit unless @retained_bytes
343
+
344
+ retained = prepared.compact.sum { |entry| retained_bytes(entry) }
345
+ [@memory_byte_limit - retained, 0].max
346
+ end
347
+
348
+ def release_retained_entries(prepared)
349
+ unless @retained_bytes
350
+ prepared.each_index do |index|
351
+ next unless prepared[index]
209
352
 
210
- if items.length >= @effective_workers || estimates.sum + estimate > @group_byte_limit
211
- groups << bounded_group(items, estimates)
212
- items = []
213
- estimates = []
353
+ prepared[index] = nil
214
354
  end
215
- items << item
216
- estimates << estimate
355
+ return
217
356
  end
218
- groups << bounded_group(items, estimates) unless items.empty?
219
- groups
357
+
358
+ prepared.each_index do |index|
359
+ entry = prepared[index]
360
+ next unless entry && retained_bytes(entry).positive?
361
+
362
+ prepared[index] = nil
363
+ end
364
+ end
365
+
366
+ def retained_bytes(entry)
367
+ bytes = Integer(@retained_bytes.call(entry))
368
+ raise ArgumentError, "retained_bytes must return a non-negative integer" if bytes.negative?
369
+
370
+ bytes
371
+ end
372
+
373
+ def build_groups
374
+ return equal_groups.each unless @estimate_bytes
375
+
376
+ GroupCursor.new(
377
+ items: @items,
378
+ workers: @effective_workers,
379
+ group_byte_limit: @group_byte_limit,
380
+ memory_byte_limit: @memory_byte_limit,
381
+ estimated_reservation: method(:estimated_reservation),
382
+ unknown_reservation: unknown_reservation,
383
+ bounded_group: method(:bounded_group)
384
+ )
220
385
  end
221
386
 
222
387
  def equal_groups
@@ -238,6 +403,10 @@ module Cohere
238
403
  nil
239
404
  end
240
405
 
406
+ def unknown_reservation
407
+ [@group_byte_limit / @effective_workers, 1].max
408
+ end
409
+
241
410
  def bounded_group(items, estimates)
242
411
  limits = estimates.dup
243
412
  remaining = @group_byte_limit - limits.sum
@@ -261,7 +430,15 @@ module Cohere
261
430
  end
262
431
  end
263
432
  ensure
433
+ release(prepared)
434
+ end
435
+
436
+ def release(prepared)
264
437
  prepared&.clear
438
+ collect_released_audio
439
+ end
440
+
441
+ def collect_released_audio
265
442
  GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true)
266
443
  end
267
444
 
@@ -3,6 +3,8 @@
3
3
  require "monitor"
4
4
  require "weakref"
5
5
 
6
+ require_relative "../internal/session_ownership"
7
+
6
8
  module Cohere
7
9
  module Transcribe
8
10
  module Runtime
@@ -12,47 +14,6 @@ module Cohere
12
14
  class ModelResources
13
15
  OWNER_GUARD = Monitor.new
14
16
 
15
- # The global owner record and the ObjectSpace finalizer retain this
16
- # state, never the ModelResources instance itself. It also lets a new
17
- # owner synchronously evict a collected predecessor before loading.
18
- class SessionOwnership
19
- def initialize
20
- @mutex = Mutex.new
21
- @session = nil
22
- end
23
-
24
- def session
25
- @mutex.synchronize { @session }
26
- end
27
-
28
- def install(session)
29
- @mutex.synchronize do
30
- raise TranscriptionRuntimeError, "ASR session ownership is already installed" if @session
31
-
32
- @session = session
33
- end
34
- end
35
-
36
- def close
37
- session = @mutex.synchronize do
38
- current = @session
39
- @session = nil
40
- current
41
- end
42
- return unless session
43
-
44
- session.close
45
- nil
46
- end
47
-
48
- def finalize
49
- close
50
- rescue Exception # rubocop:disable Lint/RescueException -- finalizers must not escape during GC or shutdown
51
- nil
52
- end
53
- end
54
- private_constant :SessionOwnership
55
-
56
17
  class << self
57
18
  def evict_current_asr_owner
58
19
  OWNER_GUARD.synchronize do
@@ -118,7 +79,9 @@ module Cohere
118
79
 
119
80
  def initialize
120
81
  @asr_key = nil
121
- @asr_ownership = SessionOwnership.new
82
+ @asr_ownership = Internal::SessionOwnership.new(
83
+ installed_error: "ASR session ownership is already installed"
84
+ )
122
85
  @batch_controller = nil
123
86
  @closed = false
124
87
  ObjectSpace.define_finalizer(
@@ -129,8 +92,8 @@ module Cohere
129
92
 
130
93
  # Returns [session, loaded]. A key change evicts this instance's prior
131
94
  # session, while acquisition also evicts a different process owner.
132
- def acquire_asr(key)
133
- raise ArgumentError, "an ASR loader block is required" unless block_given?
95
+ def acquire_asr(key, &loader)
96
+ raise ArgumentError, "an ASR loader block is required" unless loader
134
97
 
135
98
  self.class::OWNER_GUARD.synchronize do
136
99
  ensure_open!
@@ -139,36 +102,39 @@ module Cohere
139
102
 
140
103
  owner = self.class.send(:current_owner_locked)
141
104
  owner.send(:evict_asr_locked) if owner && !owner.equal?(self)
142
- self.class.send(:claim_locked, self)
143
105
 
144
106
  loaded = false
145
- unless @asr_ownership.session
146
- installed = false
107
+ if @asr_ownership.session
108
+ self.class.send(:claim_locked, self)
109
+ else
110
+ completed = false
147
111
  session = nil
148
112
  begin
149
- session = yield
150
- raise TranscriptionRuntimeError, "ASR loader returned no native session" if session.nil?
113
+ Thread.handle_interrupt(Object => :never) do
114
+ self.class.send(:claim_locked, self)
115
+ session = Thread.handle_interrupt(Object => :on_blocking, &loader)
116
+ raise TranscriptionRuntimeError, "ASR loader returned no native session" if session.nil?
151
117
 
152
- Thread.handle_interrupt(Exception => :never) do
153
118
  @asr_ownership.install(session)
154
119
  @asr_key = owned_key
155
120
  @batch_controller = nil
156
121
  loaded = true
157
- installed = true
158
122
  end
159
- rescue Exception # rubocop:disable Lint/RescueException -- roll back asynchronous loader interruption
160
- begin
161
- if @asr_ownership.session.equal?(session)
162
- evict_asr_locked
163
- else
164
- session&.close
123
+ completed = true
124
+ ensure
125
+ unless completed
126
+ Thread.handle_interrupt(Object => :never) do
127
+ if @asr_ownership.session.equal?(session)
128
+ evict_asr_locked
129
+ else
130
+ session&.close
131
+ end
132
+ rescue Exception # rubocop:disable Lint/RescueException -- preserve loader termination
133
+ nil
134
+ ensure
135
+ self.class.send(:release_locked, self)
165
136
  end
166
- rescue Exception # rubocop:disable Lint/RescueException -- preserve the loader failure
167
- nil
168
137
  end
169
- raise
170
- ensure
171
- self.class.send(:release_locked, self) unless installed
172
138
  end
173
139
  end
174
140
  [@asr_ownership.session, loaded].freeze
@@ -32,7 +32,7 @@ module Cohere
32
32
 
33
33
  def write_asr_checkpoint(path:, result:, source_snapshot:, asr_contract_key:,
34
34
  speech_spans:, vad_provider_options: nil, generation_id: nil,
35
- directory_binding: nil, guard_bindings: nil)
35
+ directory_binding: nil, guard_bindings: nil, lock: nil)
36
36
  generation_id = generation_id.to_s
37
37
  generation_id = SecureRandom.hex(16) if generation_id.empty?
38
38
  payload = asr_checkpoint_payload(
@@ -48,7 +48,8 @@ module Cohere
48
48
  payload,
49
49
  source_snapshot: source_snapshot,
50
50
  directory_binding: directory_binding,
51
- guard_bindings: guard_bindings
51
+ guard_bindings: guard_bindings,
52
+ commit_guard: lock&.method(:verify!)
52
53
  )
53
54
  generation_id.freeze
54
55
  end