keinaufwand-sync 0.1.0 → 0.1.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: 63f03abe9ebcc27c8e013e60ed0fe51a076a6228e306deeceea89ed14014668b
4
- data.tar.gz: 8609e5ea6b0aa0d167efe1b9bc3976b233bcb6b6d84d8543986d590850b3975d
3
+ metadata.gz: 99d27c728ca508889f1696b869941416fce9bc1edccda9b808b9be1c1945be23
4
+ data.tar.gz: d2c70f51bbbfb3f53baeaa0b18c56b23b95914ec93bbe3661ca9bb15424c80b1
5
5
  SHA512:
6
- metadata.gz: 6f35691e820d95ddee550dc270a5ea96e6399e3c5568e98db9077ca60abd1c1f0b968b0668ac4601f4a8dafcd207517426aeaead24dd0e293d8069cb867c0b71
7
- data.tar.gz: f68b41369ba5e2d2022c04ca11d9ad8e954fc6eb2aa54da76f46feca3ca3add11b39b9f9a98df8dfdcf16e7f92f3fc377b3a1d5834e7cb92714670828895dd71
6
+ metadata.gz: e6fbe5748850a2abb6f6ac2f672afe2ecf7e02400b33ade8b2069fe7bc1602e48dce607bca39d50a70774fcd90bde9fca6c1a0a5740a76a42775dedabf908b96
7
+ data.tar.gz: 2f7a1e3e3244914b78e0039167d23db5d835717ba2baac1866a3618d7d93abb8b159da78a593d526249b6fc7944af6e5bbb800317124e3607bdde6a84084af50
data/README.md CHANGED
@@ -39,6 +39,20 @@ bin/rails keinaufwand:indices:generate
39
39
 
40
40
  Application models inherit from `Keinaufwand::Record`. Their `id` is the upstream ID; `id` and STI `type` form the composite primary key. Schema fields and associations use normal Active Record-style `find`, `exists?`, `where`, and `order` calls.
41
41
 
42
+ ## Console progress
43
+
44
+ Version 0.1.1 adds optional console output without changing the default quiet sync:
45
+
46
+ ```ruby
47
+ Keinaufwand::Sync.sync!(output: $stdout)
48
+ ```
49
+
50
+ Output includes the effective sync cursor, committed per-page New/Upd/Del/Skip counts, the API record total, percentage, records per second, estimated time remaining for the current resource, per-resource fetch/media/database-write timings, and a final completion summary. Lines are flushed immediately, including when stdout is redirected. `Upd` counts existing records written by the sync, even if their data was unchanged. `Skip` counts stale snapshots and deletion markers for records already absent. Counts describe this polling run; concurrent webhook writes are not included.
51
+
52
+ Progress updates after each committed page (up to 100 records). ETA uses the current resource’s average processing rate, including fetch and media time; it is approximate and shown as unknown when the API omits its total. Page fetch and media-processing messages identify work between updates.
53
+
54
+ Failed resources report the error and stop the run with the original exception. Rolled-back pages are not counted as processed, and the resource cursor only advances after the complete resource sync succeeds.
55
+
42
56
  ## Webhooks
43
57
 
44
58
  The mounted Engine accepts the exact JSON body signed by Keinaufwand in `X-Keinaufwand-Signature`. Processing is synchronous so a `200` response means the SQLite/PostgreSQL transaction committed. Event receipts make retries idempotent and preserve the latest source timestamp so an older delayed update cannot overwrite or resurrect newer data.
@@ -11,14 +11,15 @@ module Keinaufwand
11
11
  @local_asset_path_prefix = "/media"
12
12
  end
13
13
 
14
- def sync_service
14
+ def sync_service(output: nil)
15
15
  SyncService.new(
16
16
  client: fetch(:client),
17
17
  record_class: fetch(:record_class),
18
18
  sync_state_class: fetch(:sync_state_class),
19
19
  receipt_class: resolve(:webhook_receipt_class),
20
20
  resources: resources || sync_config.map { |item| item.fetch("model") }.presence || SyncService::DEFAULT_RESOURCES,
21
- asset_class: resolve(:asset_class)
21
+ asset_class: resolve(:asset_class),
22
+ output: output
22
23
  )
23
24
  end
24
25
 
@@ -88,8 +89,8 @@ module Keinaufwand
88
89
  yield configuration
89
90
  end
90
91
 
91
- def sync!
92
- configuration.sync_service.perform
92
+ def sync!(output: nil)
93
+ configuration.sync_service(output: output).perform
93
94
  end
94
95
 
95
96
  def resync!
@@ -5,53 +5,112 @@ module Keinaufwand
5
5
  Location Product Session EventDetail Event Post InstructorGroup
6
6
  ].freeze
7
7
 
8
- def initialize(client:, record_class:, sync_state_class:, resources: DEFAULT_RESOURCES, asset_class: nil, receipt_class: nil)
8
+ def initialize(client:, record_class:, sync_state_class:, resources: DEFAULT_RESOURCES, asset_class: nil, receipt_class: nil, output: nil)
9
9
  @client = client
10
10
  @record_class = record_class
11
11
  @sync_state_class = sync_state_class
12
12
  @resources = resources
13
13
  @asset_class = asset_class
14
14
  @receipt_class = receipt_class
15
+ @output = output
15
16
  end
16
17
 
17
18
  def perform
18
- @resources.each { |resource| sync_resource(resource) }
19
+ started = monotonic_time
20
+ succeeded = 0
21
+ log "Starting Keinaufwand Sync (#{@resources.size} resources)"
22
+ @resources.each_with_index do |resource, index|
23
+ begin
24
+ sync_resource(resource, index + 1)
25
+ succeeded += 1
26
+ rescue StandardError => error
27
+ log "!! FAILED #{resource}: #{error.message}"
28
+ log "Sync stopped (#{succeeded}/#{@resources.size} succeeded)"
29
+ raise
30
+ end
31
+ end
32
+ log "Sync Process Completed in #{format('%.2f', monotonic_time - started)}s (#{succeeded}/#{@resources.size} succeeded)"
19
33
  end
20
34
 
21
35
  private
22
36
 
23
- def sync_resource(resource)
37
+ def sync_resource(resource, position)
38
+ started = monotonic_time
39
+ stats = { created: 0, updated: 0, destroyed: 0, skipped: 0 }
40
+ processed = 0
41
+ pages = 0
42
+ timings = { fetch: 0.0, media: 0.0, write: 0.0 }
24
43
  cursor = @sync_state_class.cursor_for(resource)
25
44
  params = { limit: 100 }
26
45
  params[:updated_since] = (cursor - 10.seconds).iso8601 if cursor
46
+ log "[#{position}/#{@resources.size}] #{resource}: syncing since #{params[:updated_since] || 'beginning'}..."
27
47
 
48
+ fetch_started = monotonic_time
28
49
  page = @client.public_send("retrieve_#{resource.pluralize.underscore}", params)
50
+ timings[:fetch] += monotonic_time - fetch_started
51
+ total = page.meta.dig("pagination", "count") if page.respond_to?(:meta)
29
52
  latest = nil
30
53
 
31
54
  while page && !page.empty?
55
+ log " Page #{pages + 1}: syncing #{page.count} records#{' and media' if @asset_class}..."
56
+ media_started = monotonic_time
32
57
  page.each do |record|
33
58
  sync_assets(record.raw_attributes) if @asset_class && record.raw_attributes[:_deleted] != true
34
59
  end
60
+ timings[:media] += monotonic_time - media_started
35
61
 
62
+ write_started = monotonic_time
63
+ page_stats = { created: 0, updated: 0, destroyed: 0, skipped: 0 }
36
64
  @record_class.transaction do
65
+ existing_ids = @output ? @record_class.where(type: resource, id: page.map(&:id)).pluck(:id) : []
37
66
  rows = page.filter_map do |record|
38
67
  latest = latest_timestamp(latest, record.server_updated_at)
39
- next if stale?(resource, record)
68
+ if stale?(resource, record)
69
+ page_stats[:skipped] += 1
70
+ next
71
+ end
40
72
 
41
73
  if record.raw_attributes[:_deleted] == true
42
- delete_record(resource, record.id)
74
+ deleted = delete_record(resource, record.id)
75
+ page_stats[deleted.positive? ? :destroyed : :skipped] += 1
43
76
  next
44
77
  end
45
78
 
79
+ page_stats[existing_ids.include?(record.id) ? :updated : :created] += 1
46
80
  { id: record.id, type: resource, data: record.raw_attributes.except(:id), created_at: Time.current, updated_at: Time.current }
47
81
  end
48
82
 
49
83
  @record_class.upsert_all(rows, unique_by: %i[id type], update_only: %w[data updated_at]) if rows.any?
50
84
  end
85
+ timings[:write] += monotonic_time - write_started
86
+ stats.merge!(page_stats) { |_key, total, count| total + count }
87
+ pages += 1
88
+ processed += page.count
89
+ rate = processed / [monotonic_time - started, 0.001].max
90
+ progress = total && total.positive? ? "#{processed}/#{total} (#{[processed * 100 / total, 100].min}%)" : processed.to_s
91
+ eta = total && total.positive? ? "#{format('%.0f', [total - processed, 0].max / rate)}s" : "unknown"
92
+ log " Processed: #{progress} (New: #{stats[:created]} | Upd: #{stats[:updated]} | Del: #{stats[:destroyed]} | Skip: #{stats[:skipped]}) [#{format('%.1f', rate)} records/s | ETA: #{eta}]"
93
+
94
+ log " Fetching next page..." if page.respond_to?(:meta) && page.meta.dig("pagination", "next")
95
+ fetch_started = monotonic_time
51
96
  page = page.next
97
+ timings[:fetch] += monotonic_time - fetch_started
52
98
  end
53
99
 
54
100
  @sync_state_class.update_cursor!(resource, latest) if latest
101
+ elapsed = monotonic_time - started
102
+ log " Sync Report: #{resource} (#{pages} pages, #{processed} records in #{format('%.1f', elapsed)}s)"
103
+ log " Fetch: #{format('%.2f', timings[:fetch])}s | Media: #{format('%.2f', timings[:media])}s | Write: #{format('%.2f', timings[:write])}s"
104
+ log " Overall: #{format('%.1f', processed / [elapsed, 0.001].max)} records/s"
105
+ end
106
+
107
+ def monotonic_time
108
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+ end
110
+
111
+ def log(message)
112
+ @output&.puts(message)
113
+ @output&.flush
55
114
  end
56
115
 
57
116
  def delete_record(resource, id)
@@ -1,5 +1,5 @@
1
1
  module Keinaufwand
2
2
  module Sync
3
- VERSION = "0.1.0"
3
+ VERSION = "0.1.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keinaufwand-sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Codegestalt