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 +4 -4
- data/README.md +14 -0
- data/lib/keinaufwand/sync_core/configuration.rb +5 -4
- data/lib/keinaufwand/sync_core/sync_service.rb +64 -5
- data/lib/keinaufwand/sync_core/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99d27c728ca508889f1696b869941416fce9bc1edccda9b808b9be1c1945be23
|
|
4
|
+
data.tar.gz: d2c70f51bbbfb3f53baeaa0b18c56b23b95914ec93bbe3661ca9bb15424c80b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|