openc3-cosmos-cfdp 2.4.0 → 2.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb7d5721bf3a141152421c54cdbbeeb08949e23e0ab618c0d2877352325f785f
4
- data.tar.gz: 11b2caf95f49b3d27462f89763f14818bedd2c512b20c4c3e2ea35593539a9e7
3
+ metadata.gz: 4a885fc317d2f4edaa156c1f3a44adce0a75f3ebc2673909f94d404011876361
4
+ data.tar.gz: 7824550d0da234cdb343886ad2d1e69e338d69b65b8cc31010e025578abf799b
5
5
  SHA512:
6
- metadata.gz: 36fa4f2bcd087bbeb8e61b31c25d5a6a56df9929a44a5b8d4a881bd7fee91a8c1bcf6e5b1a602c9fc31467f36cac23b9e441db75904ac133990860a7861da124
7
- data.tar.gz: c8d6ba0368c1e813e0a618a6fc6d9ed6f212d5a7097ad9c63b49016020228c5fcc20d4fbf2f199223dc12bd7360f05f85e809930e90878350f28f448be3a1d04
6
+ metadata.gz: fdbeb2b8e886d63646dba3854ce22b3ac042c16eec0580620570f9ffa04b88e78ad23c71e777183cd5852de71bafd6942bd58fbf5e060758454b6c266c43791e
7
+ data.tar.gz: b40bb71ccdc4e024a70702349e1b1e669d66eb2ff9ad5325381aa4605027df25d9927c93a8a814bd1fc8eca3164b844425c0bc23630f2366e406e60a482e8f69
data/README.md CHANGED
@@ -149,7 +149,7 @@ These settings are applied to the CFDP microservice via `OPTION <name> <value>`
149
149
  | bucket | The bucket to send/receive files from. Must be the actual S3 bucket name when using AWS S3 directly (see [Using AWS S3](#using-aws-s3-or-another-s3-service-directly)) | Valid bucket Name | nil - Serve from mounted volume |
150
150
  | prevent_received_file_overwrite | Appends a timestamp to the file name for received files if the file already exists | true or false | true |
151
151
  | allow_duplicate_transaction_ids | Allows receiving transactions with an ID that was previously used by a completed transaction by deleting the old transaction. Does not affect metadata received for a transaction that is still running, which is never treated as a conflict. | true or false | false |
152
- | transaction_cleanup_frequency_hours | How often to purge old (completed) transactions from RAM and Redis | Any integer | 24 |
152
+ | transaction_cleanup_frequency_hours | How often to check for and purge completed transactions from RAM and Redis. A check also runs at startup. How long a completed transaction is kept is set by the local entity's `transaction_retain_seconds`. | Any integer | 24 |
153
153
 
154
154
  > Note: `plugin_test_mode` is a plugin.txt build `VARIABLE` (not a MIB `OPTION`). When set true it installs two dummy entities/targets that can send/receive transactions to each other for testing. It is consumed when the plugin is built, not by the CFDP microservice.
155
155
 
@@ -171,7 +171,7 @@ These settings are applied via `OPTION <name> <value>` after declaring the `sour
171
171
  | enable_eof_nak | Send a NAK in response to a received EOF. Only the local (receiving) entity's value is used. | true or false | false |
172
172
  | transaction_closure_requested | Default closure requested setting used when this engine sends a file. Read from the local entity. | CLOSURE_REQUESTED or CLOSURE_NOT_REQUESTED | CLOSURE_REQUESTED |
173
173
  | fault_handler | Fault handler setting. Only the local (source) entity's fault handlers are consulted. | (ACK_LIMIT_REACHED, KEEP_ALIVE_LIMIT_REACHED, INVALID_TRANSMISSION_MODE, FILESTORE_REJECTION, FILE_CHECKSUM_FAILURE, FILE_SIZE_ERROR, NAK_LIMIT_REACHED, INACTIVITY_DETECTED, INVALID_FILE_STRUCTURE, CHECK_LIMIT_REACHED, or UNSUPPORTED_CHECKSUM_TYPE) followed by (ISSUE_NOTICE_OF_CANCELLATION, ISSUE_NOTICE_OF_SUSPENSION, IGNORE_ERROR, or ABANDON_TRANSACTION) | See Code |
174
- | transaction_retain_seconds | Time to keep completed transactions in seconds. Only the source entity's value is used. | Floating point value greater than 0 | 86400 |
174
+ | transaction_retain_seconds | Time to keep completed (FINISHED, CANCELED or ABANDONED) transactions in seconds before they are purged. Purging happens at startup and every `transaction_cleanup_frequency_hours`, so a transaction can be kept up to that much longer. Cleanup also waits for running source threads and pending Finished acknowledgments. Transactions that never complete are not purged and must be canceled. Only the source entity's value is used. | Floating point value greater than 0 | 86400 |
175
175
 
176
176
  ### Remote (Destination) Entity Configuration
177
177
 
@@ -765,12 +765,14 @@ class CfdpMib
765
765
  CfdpTransaction.clear_saved_transaction_ids
766
766
  end
767
767
 
768
- def self.cleanup_old_transactions
768
+ def self.cleanup_old_transactions(skip_ids: [])
769
769
  current_time = Time.now.utc
770
770
  transaction_retain_seconds = @@entities[@@source_entity_id]['transaction_retain_seconds']
771
771
  cleaned_up_count = 0
772
- @@transactions.each do |id, transaction|
773
- if transaction.complete_time and (current_time - transaction.complete_time) > transaction_retain_seconds
772
+ # Iterate a copy because Rails request threads may add transactions concurrently
773
+ @@transactions.to_a.each do |id, transaction|
774
+ next if skip_ids.include?(id)
775
+ if transaction.complete_time and (current_time - transaction.complete_time) > transaction_retain_seconds and transaction.cleanup_ready?
774
776
  transaction.delete
775
777
  cleaned_up_count += 1
776
778
  end
@@ -204,6 +204,12 @@ class CfdpReceiveTransaction < CfdpTransaction
204
204
  end
205
205
  end
206
206
 
207
+ # Completion is reported before the positive acknowledgment procedure ends. Retain the
208
+ # transaction so its Finished PDU can still be retransmitted, including after a restart.
209
+ def cleanup_ready?
210
+ super && @finished_ack_timeout.nil?
211
+ end
212
+
207
213
  def complete_file_received?
208
214
  return false unless @file_size
209
215
  return true if @file_size == 0
@@ -318,6 +324,7 @@ class CfdpReceiveTransaction < CfdpTransaction
318
324
  @condition_code = "ACK_LIMIT_REACHED"
319
325
  handle_fault()
320
326
  @finished_ack_timeout = nil
327
+ save_state()
321
328
  else
322
329
  @finished_ack_timeout = Time.now + CfdpMib.entity(@source_entity_id)['ack_timer_interval']
323
330
  end
@@ -637,6 +644,7 @@ class CfdpReceiveTransaction < CfdpTransaction
637
644
  'metadata_pdu_hash' => @metadata_pdu_hash,
638
645
  'metadata_pdu_count' => @metadata_pdu_count,
639
646
  'create_time' => @create_time&.iso8601(6),
647
+ 'complete_time' => @complete_time&.iso8601(6),
640
648
  'proxy_response_info' => @proxy_response_info,
641
649
  'proxy_response_needed' => @proxy_response_needed,
642
650
  'canceling_entity_id' => @canceling_entity_id,
@@ -598,6 +598,7 @@ class CfdpSourceTransaction < CfdpTransaction
598
598
  end
599
599
  end
600
600
  @proxy_response_needed = true if @proxy_response_info
601
+ save_state()
601
602
  end
602
603
 
603
604
  def handle_pdu(pdu_hash)
@@ -724,6 +725,7 @@ class CfdpSourceTransaction < CfdpTransaction
724
725
  'metadata_pdu_hash' => @metadata_pdu_hash,
725
726
  'metadata_pdu_count' => @metadata_pdu_count,
726
727
  'create_time' => @create_time&.iso8601(6),
728
+ 'complete_time' => @complete_time&.iso8601(6),
727
729
  'proxy_response_info' => @proxy_response_info,
728
730
  'proxy_response_needed' => @proxy_response_needed,
729
731
  'canceling_entity_id' => @canceling_entity_id,
@@ -784,6 +786,9 @@ class CfdpSourceTransaction < CfdpTransaction
784
786
  @eof_ack_pdu_hash = state_data['eof_ack_pdu_hash']
785
787
  @keep_alive_pdu_hash = state_data['keep_alive_pdu_hash']
786
788
  @copy_state = state_data['copy_state']
789
+ # notice_of_completion did not used to save state, so a finished transfer could be saved
790
+ # with a copy_state of complete while its state was still ACTIVE
791
+ @complete_time ||= legacy_complete_time if @copy_state == "complete"
787
792
  @file_offset = state_data['file_offset']
788
793
  @file_checksum = state_data['file_checksum']
789
794
  @file_size = state_data['file_size']
@@ -301,6 +301,7 @@ class CfdpTransaction
301
301
  'metadata_pdu_hash' => @metadata_pdu_hash,
302
302
  'metadata_pdu_count' => @metadata_pdu_count,
303
303
  'create_time' => @create_time&.iso8601(6),
304
+ 'complete_time' => @complete_time&.iso8601(6),
304
305
  'proxy_response_info' => @proxy_response_info,
305
306
  'proxy_response_needed' => @proxy_response_needed,
306
307
  'canceling_entity_id' => @canceling_entity_id,
@@ -341,7 +342,10 @@ class CfdpTransaction
341
342
  @metadata_pdu_hash = state_data['metadata_pdu_hash']
342
343
  @metadata_pdu_count = state_data['metadata_pdu_count'] || 0
343
344
  @create_time = state_data['create_time'] ? Time.parse(state_data['create_time']) : nil
344
- @complete_time = nil # Completed transactions are not persisted
345
+ @complete_time = state_data['complete_time'] ? Time.parse(state_data['complete_time']) : nil
346
+ # State saved before complete_time was persisted has no complete_time even when the
347
+ # transaction is done, which kept it from ever being cleaned up
348
+ @complete_time ||= legacy_complete_time if ['FINISHED', 'CANCELED', 'ABANDONED'].include?(@state)
345
349
  @proxy_response_info = state_data['proxy_response_info']
346
350
  @proxy_response_needed = state_data['proxy_response_needed']
347
351
  @canceling_entity_id = state_data['canceling_entity_id']
@@ -353,6 +357,16 @@ class CfdpTransaction
353
357
  return state_data
354
358
  end
355
359
 
360
+ def cleanup_ready?
361
+ !@complete_time.nil?
362
+ end
363
+
364
+ # Best estimate of when a completed transaction loaded without a complete_time finished. The
365
+ # create_time is persisted and always precedes completion.
366
+ def legacy_complete_time
367
+ @create_time || Time.now.utc
368
+ end
369
+
356
370
  def cfdp_cmd(entity, target_name, packet_name, cmd_params, scope: ENV['OPENC3_SCOPE'])
357
371
  cmd(target_name, packet_name, cmd_params, scope: ENV['OPENC3_SCOPE'])
358
372
  cmd_delay(entity['cmd_delay']) if entity['cmd_delay']
@@ -44,7 +44,9 @@ class CfdpUser
44
44
  # Protected by @source_mutex along with @source_transactions.
45
45
  @stopping = false
46
46
  @source_mutex = Mutex.new
47
- @last_cleanup_time = Time.now.utc
47
+ # nil so cleanup runs on the first pass through the receive loop. Otherwise a microservice
48
+ # restarted more often than transaction_cleanup_frequency_hours would never clean up.
49
+ @last_cleanup_time = nil
48
50
 
49
51
  at_exit do
50
52
  stop()
@@ -53,6 +55,9 @@ class CfdpUser
53
55
 
54
56
  def start
55
57
  @source_mutex.synchronize { @stopping = false }
58
+ # Capture restored transactions before returning to Rails. New Put requests start their
59
+ # own threads and must not also be resumed after telemetry initialization finishes.
60
+ transactions_to_resume = CfdpMib.transactions.to_a
56
61
  @thread = Thread.new do
57
62
  begin
58
63
  source_entity = CfdpMib.source_entity
@@ -73,6 +78,9 @@ class CfdpUser
73
78
  else
74
79
  OpenC3::Topic.update_topic_offsets(topics)
75
80
  end
81
+ # Initialize offsets before resumed transfers can generate replies, and register their
82
+ # threads before the first cleanup pass so active transfers are protected from deletion.
83
+ resume_incomplete_source_transactions(transactions_to_resume)
76
84
  while !@cancel_thread
77
85
  # TODO: Handle freezing transactions if interface disconnects (or target goes unhealthy), and unfreezing if comes back to functional
78
86
 
@@ -86,8 +94,8 @@ class CfdpUser
86
94
 
87
95
  current_time = Time.now.utc
88
96
  frequency_seconds = CfdpMib.transaction_cleanup_frequency_hours * 3600
89
- if (current_time - @last_cleanup_time) >= frequency_seconds
90
- CfdpMib.cleanup_old_transactions()
97
+ if @last_cleanup_time.nil? or (current_time - @last_cleanup_time) >= frequency_seconds
98
+ CfdpMib.cleanup_old_transactions(skip_ids: active_source_transaction_ids())
91
99
  @last_cleanup_time = current_time
92
100
  end
93
101
  end
@@ -96,7 +104,6 @@ class CfdpUser
96
104
  end
97
105
  end
98
106
 
99
- resume_incomplete_source_transactions
100
107
  return @thread
101
108
  end
102
109
 
@@ -179,6 +186,8 @@ class CfdpUser
179
186
  proxy_responses << params
180
187
  transaction.proxy_response_needed = false
181
188
  transaction.proxy_response_info = nil
189
+ # Completed transactions are persisted, so the response would be sent again on restart
190
+ transaction.save_state
182
191
  end
183
192
  rescue => err
184
193
  # A single bad transaction must not take down the receive thread, which would leave the
@@ -199,6 +208,15 @@ class CfdpUser
199
208
  end
200
209
  end
201
210
 
211
+ # Ids of source transactions whose thread is still running. A canceled transaction already has
212
+ # a complete_time while its thread finishes sending EOF and waiting for the Finished PDU, so it
213
+ # must not be cleaned up until the thread is done with it.
214
+ def active_source_transaction_ids
215
+ @source_mutex.synchronize do
216
+ @source_transactions.filter_map { |transaction, thread| transaction.id if thread.alive? }
217
+ end
218
+ end
219
+
202
220
  def receive_packet(topic, msg_id, msg_hash, redis)
203
221
  topic_split = topic.gsub(/{|}/, '').split("__") # Remove the redis hashtag curly braces
204
222
  target_name = topic_split[2]
@@ -278,8 +296,10 @@ class CfdpUser
278
296
  return false
279
297
  end
280
298
 
281
- def resume_incomplete_source_transactions()
282
- CfdpMib.transactions.each do |transaction_id, transaction|
299
+ # transactions is a snapshot of CfdpMib.transactions taken by start before Rails can add new
300
+ # transactions concurrently. Put requests start their own threads and must not be resumed too.
301
+ def resume_incomplete_source_transactions(transactions)
302
+ transactions.each do |transaction_id, transaction|
283
303
  if transaction_id.split('__')[0].to_i == CfdpMib.source_entity_id && transaction.copy_state != "complete" # && transaction.copy_state != nil?
284
304
  # A transaction that was shut down by a previous stop() in this same process would
285
305
  # otherwise exit immediately at its first shutdown check
@@ -875,6 +875,7 @@ RSpec.describe CfdpMib do
875
875
  allow(@recent_tx).to receive(:delete)
876
876
 
877
877
  @old_tx = double('Transaction')
878
+ allow(@old_tx).to receive(:cleanup_ready?).and_return(true)
878
879
  allow(@old_tx).to receive(:complete_time).and_return(Time.now.utc - 120)
879
880
  allow(@old_tx).to receive(:delete)
880
881
 
@@ -919,9 +920,37 @@ RSpec.describe CfdpMib do
919
920
  expect(@old_tx).not_to have_received(:delete)
920
921
  end
921
922
 
923
+ it 'skips transactions in skip_ids even when they are old' do
924
+ # A canceled source transaction has a complete_time while its thread is still running
925
+ CfdpMib.cleanup_old_transactions(skip_ids: ['tx3'])
926
+ expect(@old_tx).not_to have_received(:delete)
927
+ end
928
+
929
+ %w(FINISHED CANCELED).each do |state|
930
+ it "retains a restored #{state} receiver until its Finished ACK arrives" do
931
+ id = '2__123'
932
+ state_data = { 'id' => id, 'state' => state,
933
+ 'create_time' => (Time.now.utc - 120).iso8601(6),
934
+ 'finished_ack_timeout' => (Time.now.utc + 5).iso8601(6) }
935
+ OpenC3::Store.set("#{CfdpTransaction.redis_key_prefix}cfdp_transaction_state:#{id}", JSON.generate(state_data))
936
+ transaction = CfdpReceiveTransaction.allocate
937
+ transaction.load_state(id)
938
+ CfdpMib.transactions[id] = transaction
939
+ allow(transaction).to receive(:delete)
940
+
941
+ CfdpMib.cleanup_old_transactions
942
+ expect(transaction).not_to have_received(:delete)
943
+
944
+ transaction.handle_pdu({ 'DIRECTIVE_CODE' => 'ACK' })
945
+ CfdpMib.cleanup_old_transactions
946
+ expect(transaction).to have_received(:delete)
947
+ end
948
+ end
949
+
922
950
  it 'handles multiple old transactions' do
923
951
  # Add another old transaction
924
952
  old_tx2 = double('Transaction')
953
+ allow(old_tx2).to receive(:cleanup_ready?).and_return(true)
925
954
  allow(old_tx2).to receive(:complete_time).and_return(Time.now.utc - 150)
926
955
  allow(old_tx2).to receive(:delete)
927
956
  CfdpMib.transactions['tx4'] = old_tx2
@@ -549,8 +549,11 @@ RSpec.describe CfdpReceiveTransaction do
549
549
 
550
550
  it "handles finished_ack limit reached" do
551
551
  receive_transaction = CfdpReceiveTransaction.new(@metadata_pdu_hash)
552
+ receive_transaction.instance_variable_set(:@state, "FINISHED")
553
+ receive_transaction.instance_variable_set(:@complete_time, Time.now.utc - 120)
552
554
  receive_transaction.instance_variable_set(:@finished_ack_timeout, Time.now - 1)
553
555
  receive_transaction.instance_variable_set(:@finished_pdu, "FINISHED_PDU")
556
+ receive_transaction.save_state
554
557
  expect(receive_transaction).to receive(:handle_fault)
555
558
 
556
559
  # Set count to reach limit
@@ -559,6 +562,11 @@ RSpec.describe CfdpReceiveTransaction do
559
562
 
560
563
  expect(receive_transaction.instance_variable_get(:@condition_code)).to eq("ACK_LIMIT_REACHED")
561
564
  expect(receive_transaction.instance_variable_get(:@finished_ack_timeout)).to be_nil
565
+
566
+ restored = CfdpReceiveTransaction.allocate
567
+ restored.load_state(receive_transaction.id)
568
+ expect(restored.condition_code).to eq("ACK_LIMIT_REACHED")
569
+ expect(restored.cleanup_ready?).to be true
562
570
  end
563
571
  end
564
572
 
@@ -97,6 +97,31 @@ RSpec.describe CfdpSourceTransaction do
97
97
  end
98
98
  end
99
99
 
100
+ describe "complete_time persistence" do
101
+ it "saves complete_time when notice_of_completion runs" do
102
+ mock_redis
103
+ transaction = CfdpSourceTransaction.new
104
+ transaction.notice_of_completion
105
+
106
+ loaded = CfdpSourceTransaction.new
107
+ loaded.load_state(transaction.id)
108
+ expect(loaded.state).to eq("FINISHED")
109
+ expect(loaded.complete_time.to_f).to be_within(0.001).of(transaction.complete_time.to_f)
110
+ end
111
+
112
+ it "uses create_time as complete_time for a completed copy saved without one" do
113
+ # notice_of_completion used to not save state, leaving the transaction saved as ACTIVE
114
+ mock_redis
115
+ state_data = {"id" => "1__123", "state" => "ACTIVE", "copy_state" => "complete",
116
+ "create_time" => "2023-01-01T10:00:00.000000Z"}
117
+ OpenC3::Store.set("#{CfdpTransaction.redis_key_prefix}cfdp_transaction_state:1__123", JSON.generate(state_data))
118
+
119
+ transaction = CfdpSourceTransaction.new
120
+ transaction.load_state("1__123")
121
+ expect(transaction.complete_time).to eq(Time.parse("2023-01-01T10:00:00.000000Z"))
122
+ end
123
+ end
124
+
100
125
  describe "save_state optimization during file PDU transmission" do
101
126
  before(:each) do
102
127
  mock_redis()
@@ -370,6 +370,15 @@ RSpec.describe CfdpTransaction do
370
370
  expect(state_data).not_to have_key("file_status")
371
371
  expect(state_data).not_to have_key("complete_time")
372
372
  end
373
+
374
+ it "saves complete_time when the transaction has completed" do
375
+ mock_redis
376
+ transaction.instance_variable_set(:@id, "1__123")
377
+ transaction.abandon
378
+
379
+ state_data = JSON.parse(OpenC3::Store.get("#{@redis_prefix}cfdp_transaction_state:1__123"))
380
+ expect(Time.parse(state_data["complete_time"]).to_f).to be_within(0.001).of(transaction.complete_time.to_f)
381
+ end
373
382
  end
374
383
 
375
384
  describe "load_state" do
@@ -438,6 +447,38 @@ RSpec.describe CfdpTransaction do
438
447
  expect(transaction.condition_code).to eq("NO_ERROR")
439
448
  expect(transaction.metadata_pdu_count).to eq(0)
440
449
  expect(transaction.instance_variable_get(:@fault_handler_overrides)).to eq({})
450
+ expect(transaction.complete_time).to be_nil
451
+ end
452
+
453
+ it "loads complete_time" do
454
+ mock_redis
455
+ state_data = {"id" => "1__123", "state" => "FINISHED", "create_time" => "2023-01-01T10:00:00.000000Z",
456
+ "complete_time" => "2023-01-01T11:00:00.000000Z"}
457
+ OpenC3::Store.set("#{@redis_prefix}cfdp_transaction_state:1__123", JSON.generate(state_data))
458
+
459
+ transaction.load_state("1__123")
460
+ expect(transaction.complete_time).to eq(Time.parse("2023-01-01T11:00:00.000000Z"))
461
+ end
462
+
463
+ %w(FINISHED CANCELED ABANDONED).each do |state|
464
+ it "uses create_time as complete_time for a #{state} transaction saved without one" do
465
+ # State saved by older versions never included complete_time, so these were never cleaned up
466
+ mock_redis
467
+ state_data = {"id" => "1__123", "state" => state, "create_time" => "2023-01-01T10:00:00.000000Z"}
468
+ OpenC3::Store.set("#{@redis_prefix}cfdp_transaction_state:1__123", JSON.generate(state_data))
469
+
470
+ transaction.load_state("1__123")
471
+ expect(transaction.complete_time).to eq(Time.parse("2023-01-01T10:00:00.000000Z"))
472
+ end
473
+ end
474
+
475
+ it "leaves complete_time nil for an incomplete transaction" do
476
+ mock_redis
477
+ state_data = {"id" => "1__123", "state" => "SUSPENDED", "create_time" => "2023-01-01T10:00:00.000000Z"}
478
+ OpenC3::Store.set("#{@redis_prefix}cfdp_transaction_state:1__123", JSON.generate(state_data))
479
+
480
+ transaction.load_state("1__123")
481
+ expect(transaction.complete_time).to be_nil
441
482
  end
442
483
  end
443
484
 
@@ -483,7 +524,7 @@ RSpec.describe CfdpTransaction do
483
524
  expect(new_transaction.metadata_pdu_hash).to eq("abc123")
484
525
  expect(new_transaction.metadata_pdu_count).to eq(3)
485
526
  expect(new_transaction.create_time.to_i).to eq(original_time.to_i)
486
- expect(new_transaction.complete_time).to be_nil # complete_time is not persisted
527
+ expect(new_transaction.complete_time).to be_nil # Not complete
487
528
  expect(new_transaction.proxy_response_info).to eq("proxy_info")
488
529
  expect(new_transaction.proxy_response_needed).to be true
489
530
  expect(new_transaction.instance_variable_get(:@canceling_entity_id)).to eq(2)
@@ -61,6 +61,33 @@ RSpec.describe CfdpUser, type: :model do
61
61
  end
62
62
 
63
63
  describe "start" do
64
+ it "does not resume a new Put created during telemetry initialization" do
65
+ CfdpMib.source_entity['tlm_info'] = [['CFDP', 'PDU', 'DATA']]
66
+ transaction = double('new source transaction', copy_state: nil)
67
+ expect(transaction).not_to receive(:clear_shutdown)
68
+ expect(transaction).not_to receive(:copy_file)
69
+ allow(OpenC3::Topic).to receive(:update_topic_offsets) do
70
+ CfdpMib.transactions["#{CfdpMib.source_entity_id}__999"] = transaction
71
+ @user.instance_variable_set(:@cancel_thread, true)
72
+ end
73
+
74
+ @user.start.join(5)
75
+ end
76
+
77
+ it "initializes telemetry offsets before resuming source transactions" do
78
+ CfdpMib.source_entity['tlm_info'] = [['CFDP', 'PDU', 'DATA']]
79
+ events = []
80
+ allow(OpenC3::Topic).to receive(:update_topic_offsets) { events << :offsets }
81
+ allow(@user).to receive(:resume_incomplete_source_transactions) do
82
+ events << :resume
83
+ @user.instance_variable_set(:@cancel_thread, true)
84
+ end
85
+
86
+ @user.start.join(5)
87
+
88
+ expect(events).to eq([:offsets, :resume])
89
+ end
90
+
64
91
  it "sleeps instead of spinning when tlm_info is not configured" do
65
92
  # Store.read_topics returns immediately when given no topics, so the receive loop has to
66
93
  # pace itself or it pins a CPU for the life of the process
@@ -81,6 +108,49 @@ RSpec.describe CfdpUser, type: :model do
81
108
  expect(sleeps).to eq(2)
82
109
  expect(OpenC3::Logger).to have_received(:error).with(/tlm_info is not configured/, anything)
83
110
  end
111
+
112
+ it "cleans up old transactions on the first pass and then only every transaction_cleanup_frequency_hours" do
113
+ # A microservice restarted more often than the cleanup frequency must still clean up
114
+ CfdpMib.source_entity['tlm_info'] = []
115
+ allow(OpenC3::Logger).to receive(:error)
116
+ allow(CfdpMib).to receive(:cleanup_old_transactions)
117
+
118
+ sleeps = 0
119
+ allow(@user).to receive(:sleep) do |seconds|
120
+ next unless seconds == CfdpUser::READ_TOPICS_TIMEOUT_S
121
+ sleeps += 1
122
+ @user.instance_variable_set(:@cancel_thread, true) if sleeps >= 3
123
+ end
124
+
125
+ @user.start.join(5)
126
+
127
+ expect(CfdpMib).to have_received(:cleanup_old_transactions).once
128
+ end
129
+
130
+ it "does not clean up transactions whose resumed source thread is still running" do
131
+ # A transaction canceled before a restart has an old complete_time but its source thread is
132
+ # resumed to finish sending EOF, so the first cleanup pass must not delete it out from under it
133
+ CfdpMib.source_entity['tlm_info'] = []
134
+ allow(OpenC3::Logger).to receive(:error)
135
+ cleanup_skip_ids = nil
136
+ allow(CfdpMib).to receive(:cleanup_old_transactions) { |skip_ids: []| cleanup_skip_ids = skip_ids }
137
+
138
+ release = Queue.new
139
+ transaction = double("transaction", id: "1__5", request_shutdown: nil, save_state: nil)
140
+ allow(@user).to receive(:resume_incomplete_source_transactions) do
141
+ @user.register_source_transaction(transaction, Thread.new { release.pop })
142
+ end
143
+
144
+ allow(@user).to receive(:sleep) do |seconds|
145
+ next unless seconds == CfdpUser::READ_TOPICS_TIMEOUT_S
146
+ @user.instance_variable_set(:@cancel_thread, true)
147
+ end
148
+
149
+ @user.start.join(5)
150
+ release << true
151
+
152
+ expect(cleanup_skip_ids).to eq(["1__5"])
153
+ end
84
154
  end
85
155
 
86
156
  describe "update_transactions" do
@@ -98,6 +168,26 @@ RSpec.describe CfdpUser, type: :model do
98
168
  expect(good).to have_received(:update)
99
169
  expect(OpenC3::Logger).to have_received(:error).with(/1__1 update failed/, anything)
100
170
  end
171
+
172
+ it "persists that the proxy put response was sent" do
173
+ # The completed transaction is saved with proxy_response_needed set, so a restart would send
174
+ # the response again unless clearing it is saved too
175
+ transaction = CfdpSourceTransaction.new
176
+ transaction.proxy_response_info = { "SOURCE_ENTITY_ID" => @destination_entity_id, "SEQUENCE_NUMBER" => 5 }
177
+ transaction.notice_of_completion
178
+ allow(transaction).to receive(:update)
179
+ pdu = double("pdu", build_proxy_put_response_message: "response", build_originating_transaction_id_message: "originating")
180
+ allow(CfdpPdu).to receive(:build_initial_pdu).and_return(pdu)
181
+ allow(@user).to receive(:start_source_transaction)
182
+
183
+ @user.update_transactions
184
+
185
+ expect(@user).to have_received(:start_source_transaction).once
186
+ loaded = CfdpSourceTransaction.new
187
+ loaded.load_state(transaction.id)
188
+ expect(loaded.proxy_response_needed).to be false
189
+ expect(loaded.proxy_response_info).to be_nil
190
+ end
101
191
  end
102
192
 
103
193
  describe "reap_source_transactions" do
@@ -1014,4 +1104,4 @@ RSpec.describe CfdpUser, type: :model do
1014
1104
  expect(@user.instance_variable_get(:@source_transactions)).to eq([[transaction, source_thread]])
1015
1105
  end
1016
1106
  end
1017
- end
1107
+ end
data/plugin.txt CHANGED
@@ -59,7 +59,7 @@ VARIABLE allow_duplicate_transaction_ids false
59
59
  VARIABLE_STATE "TRUE" true
60
60
  VARIABLE_STATE "FALSE" false
61
61
  VARIABLE transaction_cleanup_frequency_hours 24
62
- VARIABLE_DESCRIPTION "How often to purge old (completed) transactions from RAM and Redis"
62
+ VARIABLE_DESCRIPTION "How often (and at startup) to purge completed transactions older than the source entity transaction_retain_seconds (default 86400) from RAM and Redis"
63
63
 
64
64
  VARIABLE plugin_test_mode false
65
65
  VARIABLE_DESCRIPTION "Install 2 targets: CFDP and CFDP2 along with the CFDP2 microservice configured to talk to CFDP2 target for testing purposes."
data/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openc3-cosmos-cfdp"
3
- version = "2.2.0"
3
+ version = "0.0.0"
4
4
  description = "OpenC3 CFDP - Python Implementation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openc3-cosmos-cfdp
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 2.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Melton
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-10-02 00:00:00.000000000 Z
12
+ date: 2026-11-13 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description: This plugin provides COSMOS Support for CCSDS File Delivery Protocol
15
15
  (CFDP). It includes a COSMOS API for sending CFDP commands and an example COSMOS