neo4j_bolt 0.4.0 → 0.4.2
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 +18 -9
- data/bin/neo4j_bolt +9 -2
- data/lib/neo4j_bolt/version.rb +1 -1
- data/lib/neo4j_bolt.rb +446 -49
- 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: 66da85ac04ffd0ef43fea4fba9a676ea2168770f85825573652a58dbc0a48f2b
|
|
4
|
+
data.tar.gz: 132911b813e88d5a3efb590fe5cd29da0c8adb6663b06b9931bb0a03f4fcae9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37e9205800f8661683bc246c6a962a15254c2d1e4a33d5b948f85d5e59d9ac38911e67c4a2e18bb318bc9f3ceeb3eedb7199004e98b0ce1694ae2e0f16f9ce8e
|
|
7
|
+
data.tar.gz: 3f7066485f251b5939b9a448b441290f16714311ebd7baa2b2baa27d785ae2463902c63b6c14b532df5adfe574242c8e415305967677110d92943a431e4360d3
|
data/README.md
CHANGED
|
@@ -4,14 +4,14 @@ Neo4jBolt 0.4 is a small compatibility and convenience layer for Ruby applicatio
|
|
|
4
4
|
|
|
5
5
|
Neo4jBolt no longer implements the Bolt wire protocol itself.
|
|
6
6
|
|
|
7
|
-
`0.4.0
|
|
7
|
+
`0.4.0` requires Ruby 3.4 or newer and pins `neo4j-ruby-driver` to `6.2.1.beta.4`. Applications on older Rubies can remain on the Neo4jBolt 0.3.x line; this prerelease is intentionally not an automatic upgrade for them.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
11
|
For this prerelease, specify the version explicitly:
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
|
-
gem "neo4j_bolt", "0.4.0
|
|
14
|
+
gem "neo4j_bolt", "0.4.0"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Then run `bundle install`. A running Neo4j database is required.
|
|
@@ -151,13 +151,21 @@ r {"from":0,"to":1,"type":"KNOWS","properties":{"since":2024}}
|
|
|
151
151
|
```
|
|
152
152
|
|
|
153
153
|
```ruby
|
|
154
|
-
File.open("database.dump", "w") { |io| dump_database(io) }
|
|
155
|
-
File.open("database.dump", "r")
|
|
154
|
+
File.open("database.dump", "w") { |io| dump_database(io, progress_io: $stderr) }
|
|
155
|
+
File.open("database.dump", "r") do |io|
|
|
156
|
+
load_database_dump(io, progress_io: $stderr)
|
|
157
|
+
end
|
|
156
158
|
```
|
|
157
159
|
|
|
158
|
-
|
|
160
|
+
Passing `progress_io:` is optional for the Ruby API. The CLI always reports dump/load progress on stderr, so dump data written to stdout remains safe to redirect or pipe. In `auto` mode a terminal gets a colored, in-place progress bar while redirected stderr receives periodic plain-text progress lines. Set `NEO4J_BOLT_PROGRESS=pretty` to force the terminal display through a container or other wrapper that hides the TTY, or `NEO4J_BOLT_PROGRESS=plain` to force log-friendly output. `NO_COLOR` disables colors without disabling the in-place display.
|
|
161
|
+
|
|
162
|
+
The `0, 1, 2, ...` IDs are synthetic dump-local IDs. Database-internal IDs and element IDs are never written to the persistent format. Dumping keeps the count, node, and relationship reads in one transaction, so the driver-provided entity identity used to connect the two streamed result sets stays within Neo4j's transaction-scoped identity guarantee. To keep dump numbering deterministic, ordering uses `elementId()` on modern Neo4j and an internal `id()` fallback only on Neo4j 4.4, where `elementId()` does not exist. Old Neo4jBolt dumps remain loadable.
|
|
163
|
+
|
|
164
|
+
For relational loads, the adapter assigns a random temporary label and dump-ID property to imported nodes. After the nodes are committed it builds a temporary `neo4j_bolt_` index on that property, uses indexed lookups while creating relationships, drops the index, and removes the temporary metadata in batches. No database-internal identity is carried from one load transaction to another. Loading a relational dump therefore requires permission to create and drop an index.
|
|
159
165
|
|
|
160
|
-
|
|
166
|
+
Loads start with batches of 5,000 records by default. This is an initial ceiling rather than a claimed optimum: if Neo4j returns a transaction-memory/resource error whose server semantics guarantee rollback, the loader halves the failed batch and retries it, then keeps the smaller size for the rest of that phase. Node and relationship phases adapt independently. Other errors are not retried, because a generic connection failure cannot safely prove that a `CREATE` transaction did not commit. Callers can change the initial ceiling with `initial_batch_size:`; the CLI exposes the same setting as `--batch-size`.
|
|
167
|
+
|
|
168
|
+
Loading requires an empty database unless `force_append: true` is passed. `load_database_dump` owns its batch transactions and therefore rejects being called from inside `transaction`.
|
|
161
169
|
|
|
162
170
|
## CLI
|
|
163
171
|
|
|
@@ -168,7 +176,7 @@ The `neo4j_bolt` executable retains these commands:
|
|
|
168
176
|
| `neo4j_bolt console` | Open an IRB console with Neo4jBolt loaded |
|
|
169
177
|
| `neo4j_bolt clear --srsly` | Delete all nodes and relationships |
|
|
170
178
|
| `neo4j_bolt dump` | Write the textual database dump |
|
|
171
|
-
| `neo4j_bolt load [--force] PATH` | Load a textual dump |
|
|
179
|
+
| `neo4j_bolt load [--force] [--batch-size N] PATH` | Load a textual dump |
|
|
172
180
|
| `neo4j_bolt index ls` | List constraints and indexes |
|
|
173
181
|
| `neo4j_bolt index rm --force` | Remove all constraints and indexes |
|
|
174
182
|
| `neo4j_bolt visualize` | Generate a GraphViz document |
|
|
@@ -183,7 +191,7 @@ The same complete integration suite is run against these exact Community images:
|
|
|
183
191
|
- `neo4j:5.26.28-community`
|
|
184
192
|
- `neo4j:2026.06.0-community`
|
|
185
193
|
|
|
186
|
-
No compatibility beyond this matrix is claimed for `0.4.0
|
|
194
|
+
No compatibility beyond this matrix is claimed for `0.4.0`.
|
|
187
195
|
|
|
188
196
|
Run one modern LTS target:
|
|
189
197
|
|
|
@@ -220,7 +228,7 @@ To point RSpec itself at an already disposable database, set `NEO4J_BOLT_TEST_HO
|
|
|
220
228
|
| CLI commands | Preserved |
|
|
221
229
|
| `BoltSocket`, `BoltBuffer`, protocol markers/state/parser/packer | Intentionally removed private implementation details |
|
|
222
230
|
|
|
223
|
-
## 0.4.0
|
|
231
|
+
## 0.4.0 migration notes
|
|
224
232
|
|
|
225
233
|
- Ruby 3.4 or newer is required; Ruby 2.x/3.0–3.3 applications should stay on 0.3.x until upgraded.
|
|
226
234
|
- The exact prerelease upstream dependency is pinned while no stable `neo4j-ruby-driver` 6.2.x exists.
|
|
@@ -233,3 +241,4 @@ To point RSpec itself at an already disposable database, set `NEO4J_BOLT_TEST_HO
|
|
|
233
241
|
Run `bin/setup`, then `bundle exec rake spec` or `bundle exec rake spec:matrix`. Build without publishing with `bundle exec rake build`.
|
|
234
242
|
|
|
235
243
|
Bug reports and pull requests are welcome at <https://github.com/specht/neo4j_bolt>.
|
|
244
|
+
|
data/bin/neo4j_bolt
CHANGED
|
@@ -49,7 +49,7 @@ class App
|
|
|
49
49
|
c.flag [:o, 'out-file'.to_sym], :default_value => '/dev/stdout'
|
|
50
50
|
c.action do |global_options, options|
|
|
51
51
|
File.open(options['out-file'.to_sym], 'w') do |f|
|
|
52
|
-
dump_database(f)
|
|
52
|
+
dump_database(f, progress_io: $stderr)
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
end
|
|
@@ -61,11 +61,18 @@ class App
|
|
|
61
61
|
command :load do |c|
|
|
62
62
|
# c.flag [:i, :in_file], :desc => 'input path', :required => true
|
|
63
63
|
c.switch [:f, :force], :default_value => false, :desc => 'force appending nodes even if the database is not empty'
|
|
64
|
+
c.flag [:b, 'batch-size'.to_sym], :default_value => Neo4jBolt::LOAD_INITIAL_BATCH_SIZE,
|
|
65
|
+
:desc => 'initial batch size; automatically reduced on transaction-memory errors'
|
|
64
66
|
c.action do |global_options, options, args|
|
|
65
67
|
help_now!('input path is required') if args.empty?
|
|
66
68
|
path = args.shift
|
|
67
69
|
File.open(path, 'r') do |f|
|
|
68
|
-
load_database_dump(
|
|
70
|
+
load_database_dump(
|
|
71
|
+
f,
|
|
72
|
+
force_append: options[:force],
|
|
73
|
+
progress_io: $stderr,
|
|
74
|
+
initial_batch_size: options['batch-size'.to_sym]
|
|
75
|
+
)
|
|
69
76
|
end
|
|
70
77
|
end
|
|
71
78
|
end
|
data/lib/neo4j_bolt/version.rb
CHANGED
data/lib/neo4j_bolt.rb
CHANGED
|
@@ -9,6 +9,201 @@ require "neo4j_bolt/version"
|
|
|
9
9
|
|
|
10
10
|
module Neo4jBolt
|
|
11
11
|
CONSTRAINT_INDEX_PREFIX = "neo4j_bolt_"
|
|
12
|
+
LOAD_INITIAL_BATCH_SIZE = 5_000
|
|
13
|
+
LOAD_MEMORY_ERROR_CODES = %w[
|
|
14
|
+
Neo.ClientError.General.TransactionOutOfMemoryError
|
|
15
|
+
Neo.TransientError.General.MemoryPoolOutOfMemoryError
|
|
16
|
+
Neo.TransientError.General.TransactionMemoryLimit
|
|
17
|
+
].freeze
|
|
18
|
+
LOAD_MEMORY_GQL_STATUSES = %w[51N72 51N73].freeze
|
|
19
|
+
LOAD_PROGRESS_STEP = 5_000
|
|
20
|
+
DUMP_PROGRESS_STEP = 1_000
|
|
21
|
+
private_constant :LOAD_MEMORY_ERROR_CODES, :LOAD_MEMORY_GQL_STATUSES,
|
|
22
|
+
:LOAD_PROGRESS_STEP, :DUMP_PROGRESS_STEP
|
|
23
|
+
|
|
24
|
+
class ProgressReporter
|
|
25
|
+
UPDATE_INTERVAL = 0.5
|
|
26
|
+
BAR_WIDTH = 24
|
|
27
|
+
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
28
|
+
ANSI = {
|
|
29
|
+
reset: "\e[0m",
|
|
30
|
+
bold: "\e[1m",
|
|
31
|
+
dim: "\e[2m",
|
|
32
|
+
cyan: "\e[36m",
|
|
33
|
+
green: "\e[32m",
|
|
34
|
+
yellow: "\e[33m"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def initialize(io)
|
|
38
|
+
@io = io
|
|
39
|
+
mode = ENV.fetch("NEO4J_BOLT_PROGRESS", "auto")
|
|
40
|
+
unless %w[auto pretty plain].include?(mode)
|
|
41
|
+
raise ArgumentError, "NEO4J_BOLT_PROGRESS must be auto, pretty, or plain"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
detected_tty = io && io.respond_to?(:tty?) && io.tty? && Neo4jBolt.bolt_verbosity.to_i.zero?
|
|
45
|
+
@pretty = io && (mode == "pretty" || (mode == "auto" && detected_tty))
|
|
46
|
+
@color = @pretty && !ENV.key?("NO_COLOR") && (mode == "pretty" || ENV["TERM"] != "dumb")
|
|
47
|
+
@last_update_at = nil
|
|
48
|
+
@line_open = false
|
|
49
|
+
@spinner_index = 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def update(message, force: false, percent: nil)
|
|
53
|
+
return unless @io
|
|
54
|
+
|
|
55
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
return if !force && @last_update_at && now - @last_update_at < UPDATE_INTERVAL
|
|
57
|
+
|
|
58
|
+
if @pretty
|
|
59
|
+
percent ||= extract_percent(message)
|
|
60
|
+
render_line(activity_line(message, percent))
|
|
61
|
+
else
|
|
62
|
+
@io.puts message
|
|
63
|
+
end
|
|
64
|
+
@last_update_at = now
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def note(message, level: :info)
|
|
68
|
+
return unless @io
|
|
69
|
+
|
|
70
|
+
if @pretty && level == :info
|
|
71
|
+
update(message, force: true)
|
|
72
|
+
else
|
|
73
|
+
clear_line
|
|
74
|
+
if @pretty
|
|
75
|
+
icon = level == :warning ? color(:yellow, "!") : color(:cyan, "•")
|
|
76
|
+
@io.puts "#{icon} #{terminal_text(message)}"
|
|
77
|
+
else
|
|
78
|
+
@io.puts message
|
|
79
|
+
end
|
|
80
|
+
@last_update_at = nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def finish(message)
|
|
85
|
+
return unless @io
|
|
86
|
+
|
|
87
|
+
if @pretty
|
|
88
|
+
clear_line
|
|
89
|
+
label, detail = terminal_parts(message)
|
|
90
|
+
line = "#{color(:green, '✓')} #{color(:bold, label)}"
|
|
91
|
+
line += " #{detail}" unless detail.empty?
|
|
92
|
+
@io.puts line
|
|
93
|
+
@line_open = false
|
|
94
|
+
else
|
|
95
|
+
@io.puts message
|
|
96
|
+
end
|
|
97
|
+
@last_update_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def close
|
|
101
|
+
return unless @io && @pretty && @line_open
|
|
102
|
+
|
|
103
|
+
clear_line
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def activity_line(message, percent)
|
|
109
|
+
label, detail = terminal_parts(message, strip_percent: !percent.nil?)
|
|
110
|
+
spinner = SPINNER[@spinner_index % SPINNER.length]
|
|
111
|
+
@spinner_index += 1
|
|
112
|
+
|
|
113
|
+
parts = [color(:cyan, spinner), color(:bold, label)]
|
|
114
|
+
if percent
|
|
115
|
+
percent = [[percent.to_i, 0].max, 100].min
|
|
116
|
+
parts << progress_bar(percent)
|
|
117
|
+
parts << color(:bold, format("%3d%%", percent))
|
|
118
|
+
end
|
|
119
|
+
parts << detail unless detail.empty?
|
|
120
|
+
parts.join(" ")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def progress_bar(percent)
|
|
124
|
+
filled = (percent * BAR_WIDTH / 100.0).round
|
|
125
|
+
empty = BAR_WIDTH - filled
|
|
126
|
+
"#{color(:cyan, '━' * filled)}#{color(:dim, '─' * empty)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def terminal_parts(message, strip_percent: false)
|
|
130
|
+
text = message.sub(/\.\.\.\z/, "")
|
|
131
|
+
label, detail = text.split(": ", 2)
|
|
132
|
+
label = pretty_label(label)
|
|
133
|
+
detail ||= ""
|
|
134
|
+
if strip_percent
|
|
135
|
+
detail = detail.sub(/\s+\(\d+%\)/, "")
|
|
136
|
+
detail = "" if detail.match?(/\A\d+%\z/)
|
|
137
|
+
end
|
|
138
|
+
detail = detail.gsub(/ \[batch (\d+)\]/) do
|
|
139
|
+
" #{color(:dim, '·')} batch #{format_count(Regexp.last_match(1))}"
|
|
140
|
+
end
|
|
141
|
+
detail = format_counts(detail)
|
|
142
|
+
detail = detail.gsub(", ", " #{color(:dim, '·')} ")
|
|
143
|
+
[label, detail]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def terminal_text(message)
|
|
147
|
+
label, detail = terminal_parts(message)
|
|
148
|
+
detail.empty? ? format_counts(label) : "#{label}: #{detail}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def pretty_label(label)
|
|
152
|
+
case label
|
|
153
|
+
when "Building temporary relationship lookup index"
|
|
154
|
+
"Building lookup index"
|
|
155
|
+
when "Temporary relationship lookup index ready."
|
|
156
|
+
"Lookup index ready"
|
|
157
|
+
when "Cleaning temporary load metadata"
|
|
158
|
+
"Cleaning load metadata"
|
|
159
|
+
when "Temporary load metadata removed."
|
|
160
|
+
"Load metadata removed"
|
|
161
|
+
else
|
|
162
|
+
label
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def extract_percent(message)
|
|
167
|
+
if (match = message.match(/\((\d+)%\)/)) || (match = message.match(/:\s*(\d+)%\z/))
|
|
168
|
+
return match[1].to_i
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
match = message.match(/:\s*(\d+)\/(\d+) nodes\z/)
|
|
172
|
+
return nil unless match
|
|
173
|
+
|
|
174
|
+
total = match[2].to_i
|
|
175
|
+
total.zero? ? 100 : match[1].to_i * 100 / total
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def format_counts(text)
|
|
179
|
+
text.gsub(/\b\d{4,}\b/) { |number| format_count(number) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def format_count(number)
|
|
183
|
+
number.to_s.reverse.scan(/.{1,3}/).join(",").reverse
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def render_line(line)
|
|
187
|
+
@io.print "\r\e[2K#{line}"
|
|
188
|
+
@io.flush
|
|
189
|
+
@line_open = true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def clear_line
|
|
193
|
+
return unless @pretty && @line_open
|
|
194
|
+
|
|
195
|
+
@io.print "\r\e[2K"
|
|
196
|
+
@io.flush
|
|
197
|
+
@line_open = false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def color(style, text)
|
|
201
|
+
return text unless @color
|
|
202
|
+
|
|
203
|
+
"#{ANSI.fetch(style)}#{text}#{ANSI[:reset]}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
private_constant :ProgressReporter
|
|
12
207
|
MIN_INTEGER = -(2**63)
|
|
13
208
|
MAX_INTEGER = (2**63) - 1
|
|
14
209
|
TRANSACTION_CONTEXT_KEY = :neo4j_bolt_transaction_contexts
|
|
@@ -319,95 +514,201 @@ module Neo4jBolt
|
|
|
319
514
|
nil
|
|
320
515
|
end
|
|
321
516
|
|
|
322
|
-
def dump_database(io)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
517
|
+
def dump_database(io, progress_io: nil)
|
|
518
|
+
progress = ProgressReporter.new(progress_io)
|
|
519
|
+
dumped_nodes = 0
|
|
520
|
+
dumped_relationships = 0
|
|
326
521
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
522
|
+
transaction do
|
|
523
|
+
identity_function = dump_identity_function
|
|
524
|
+
total_nodes = neo4j_query_expect_one("MATCH (n) RETURN count(n) AS count")["count"]
|
|
525
|
+
total_relationships = neo4j_query_expect_one("MATCH ()-[r]->() RETURN count(r) AS count")["count"]
|
|
526
|
+
dump_ids = total_relationships.positive? ? {} : nil
|
|
527
|
+
|
|
528
|
+
progress.update(
|
|
529
|
+
progress_message("Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships),
|
|
530
|
+
force: true
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
neo4j_query(
|
|
534
|
+
"MATCH (n) " \
|
|
535
|
+
"RETURN #{identity_function}(n) AS identity, labels(n) AS labels, properties(n) AS properties " \
|
|
536
|
+
"ORDER BY #{identity_function}(n)"
|
|
537
|
+
) do |row|
|
|
538
|
+
dump_ids[row["identity"].to_s] = dumped_nodes if dump_ids
|
|
539
|
+
io.puts "n #{JSON.generate(id: dumped_nodes, labels: row["labels"], properties: row["properties"])}"
|
|
540
|
+
dumped_nodes += 1
|
|
541
|
+
if (dumped_nodes % DUMP_PROGRESS_STEP).zero?
|
|
542
|
+
progress.update(progress_message(
|
|
543
|
+
"Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships
|
|
544
|
+
))
|
|
545
|
+
end
|
|
546
|
+
end
|
|
333
547
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
548
|
+
if total_relationships.positive?
|
|
549
|
+
neo4j_query(
|
|
550
|
+
"MATCH (from)-[r]->(to) " \
|
|
551
|
+
"RETURN #{identity_function}(from) AS from_identity, " \
|
|
552
|
+
"#{identity_function}(to) AS to_identity, type(r) AS type, properties(r) AS properties " \
|
|
553
|
+
"ORDER BY #{identity_function}(r)"
|
|
554
|
+
) do |row|
|
|
555
|
+
io.puts "r #{JSON.generate(
|
|
556
|
+
from: dump_ids.fetch(row["from_identity"].to_s),
|
|
557
|
+
to: dump_ids.fetch(row["to_identity"].to_s),
|
|
558
|
+
type: row["type"],
|
|
559
|
+
properties: row["properties"]
|
|
560
|
+
)}"
|
|
561
|
+
dumped_relationships += 1
|
|
562
|
+
if (dumped_relationships % DUMP_PROGRESS_STEP).zero?
|
|
563
|
+
progress.update(progress_message(
|
|
564
|
+
"Dumping", dumped_nodes, dumped_relationships, total_nodes, total_relationships
|
|
565
|
+
))
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
344
569
|
end
|
|
570
|
+
|
|
571
|
+
progress.finish("Dumped: #{dumped_nodes} nodes, #{dumped_relationships} relationships")
|
|
345
572
|
nil
|
|
573
|
+
ensure
|
|
574
|
+
progress&.close
|
|
346
575
|
end
|
|
347
576
|
|
|
348
|
-
def load_database_dump(io, force_append: false
|
|
577
|
+
def load_database_dump(io, force_append: false, progress_io: nil,
|
|
578
|
+
initial_batch_size: LOAD_INITIAL_BATCH_SIZE)
|
|
579
|
+
raise Error, "load_database_dump cannot run inside a transaction" if transaction_context
|
|
580
|
+
|
|
581
|
+
initial_batch_size = Integer(initial_batch_size)
|
|
582
|
+
raise ArgumentError, "initial_batch_size must be positive" unless initial_batch_size.positive?
|
|
583
|
+
|
|
349
584
|
unless force_append
|
|
350
585
|
count = neo4j_query_expect_one("MATCH (n) RETURN count(n) AS count")["count"]
|
|
351
586
|
raise Error, "There are nodes in this database, exiting now." unless count.zero?
|
|
352
587
|
end
|
|
353
588
|
|
|
589
|
+
progress = ProgressReporter.new(progress_io)
|
|
354
590
|
node_batches = Hash.new { |hash, key| hash[key] = [] }
|
|
355
591
|
relationship_batches = Hash.new { |hash, key| hash[key] = [] }
|
|
356
|
-
parse_dump(io, node_batches, relationship_batches)
|
|
592
|
+
total_nodes, total_relationships = parse_dump(io, node_batches, relationship_batches, progress)
|
|
357
593
|
|
|
358
|
-
|
|
594
|
+
temporary_token = SecureRandom.hex(12)
|
|
595
|
+
temporary_label = "__neo4j_bolt_load_#{temporary_token}"
|
|
596
|
+
temporary_id_property = "__neo4j_bolt_load_id_#{temporary_token}"
|
|
597
|
+
temporary_index = "neo4j_bolt_load_#{temporary_token}"
|
|
598
|
+
quoted_temporary_label = quote_identifier(temporary_label)
|
|
359
599
|
quoted_temporary_id = quote_identifier(temporary_id_property)
|
|
600
|
+
quoted_temporary_index = quote_identifier(temporary_index)
|
|
601
|
+
needs_relationship_lookup = total_relationships.positive?
|
|
360
602
|
loaded_nodes = 0
|
|
361
603
|
loaded_relationships = 0
|
|
604
|
+
index_created = false
|
|
362
605
|
|
|
606
|
+
progress.update(
|
|
607
|
+
progress_message("Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
|
|
608
|
+
batch_size: initial_batch_size),
|
|
609
|
+
force: true
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
original_error = nil
|
|
613
|
+
cleanup_error = nil
|
|
363
614
|
begin
|
|
615
|
+
node_batch_size = initial_batch_size
|
|
364
616
|
node_batches.each_value do |nodes|
|
|
365
|
-
nodes
|
|
617
|
+
node_batch_size = adaptive_each_slice(nodes, node_batch_size, progress, "node") do |slice, current_batch_size|
|
|
366
618
|
labels = slice.first.fetch("labels")
|
|
367
619
|
label_clause = labels.map { |label| ":#{quote_identifier(label)}" }.join
|
|
620
|
+
label_clause += ":#{quoted_temporary_label}" if needs_relationship_lookup
|
|
621
|
+
temporary_assignment = if needs_relationship_lookup
|
|
622
|
+
"SET n.#{quoted_temporary_id} = item.id\n"
|
|
623
|
+
else
|
|
624
|
+
""
|
|
625
|
+
end
|
|
368
626
|
count = neo4j_query_expect_one(<<~CYPHER, nodes: slice)["count_n"]
|
|
369
627
|
UNWIND $nodes AS item
|
|
370
628
|
CREATE (n#{label_clause})
|
|
371
629
|
SET n = item.properties
|
|
372
|
-
|
|
373
|
-
RETURN count(n) AS count_n
|
|
630
|
+
#{temporary_assignment}RETURN count(n) AS count_n
|
|
374
631
|
CYPHER
|
|
375
632
|
raise Error, "Expected #{slice.size} nodes, got #{count}." unless count == slice.size
|
|
376
633
|
|
|
377
634
|
loaded_nodes += slice.size
|
|
378
|
-
|
|
635
|
+
progress.update(progress_message(
|
|
636
|
+
"Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
|
|
637
|
+
batch_size: current_batch_size
|
|
638
|
+
))
|
|
379
639
|
end
|
|
380
640
|
end
|
|
381
641
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
642
|
+
if needs_relationship_lookup
|
|
643
|
+
progress.note("Building temporary relationship lookup index...")
|
|
644
|
+
neo4j_query(
|
|
645
|
+
"CREATE INDEX #{quoted_temporary_index} " \
|
|
646
|
+
"FOR (n:#{quoted_temporary_label}) ON (n.#{quoted_temporary_id})"
|
|
647
|
+
)
|
|
648
|
+
index_created = true
|
|
649
|
+
wait_for_load_index(temporary_index, progress)
|
|
650
|
+
progress.update(
|
|
651
|
+
progress_message("Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
|
|
652
|
+
batch_size: initial_batch_size),
|
|
653
|
+
force: true
|
|
654
|
+
)
|
|
393
655
|
|
|
394
|
-
|
|
395
|
-
|
|
656
|
+
relationship_batch_size = initial_batch_size
|
|
657
|
+
relationship_batches.each do |type, relationships|
|
|
658
|
+
relationship_batch_size = adaptive_each_slice(
|
|
659
|
+
relationships, relationship_batch_size, progress, "relationship"
|
|
660
|
+
) do |slice, current_batch_size|
|
|
661
|
+
count = neo4j_query_expect_one(<<~CYPHER, relationships: slice)["count_r"]
|
|
662
|
+
UNWIND $relationships AS item
|
|
663
|
+
MATCH (from:#{quoted_temporary_label} {#{quoted_temporary_id}: item.from})
|
|
664
|
+
MATCH (to:#{quoted_temporary_label} {#{quoted_temporary_id}: item.to})
|
|
665
|
+
CREATE (from)-[r:#{quote_identifier(type)}]->(to)
|
|
666
|
+
SET r = item.properties
|
|
667
|
+
RETURN count(r) AS count_r
|
|
668
|
+
CYPHER
|
|
669
|
+
raise Error, "Expected #{slice.size} relationships, got #{count}." unless count == slice.size
|
|
670
|
+
|
|
671
|
+
loaded_relationships += slice.size
|
|
672
|
+
progress.update(progress_message(
|
|
673
|
+
"Loading", loaded_nodes, loaded_relationships, total_nodes, total_relationships,
|
|
674
|
+
batch_size: current_batch_size
|
|
675
|
+
))
|
|
676
|
+
end
|
|
396
677
|
end
|
|
397
678
|
end
|
|
679
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- preserve original failure through cleanup
|
|
680
|
+
original_error = error
|
|
681
|
+
raise
|
|
398
682
|
ensure
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
683
|
+
if index_created
|
|
684
|
+
begin
|
|
685
|
+
neo4j_query("DROP INDEX #{quoted_temporary_index} IF EXISTS")
|
|
686
|
+
rescue Error => error
|
|
687
|
+
cleanup_error ||= error
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
if needs_relationship_lookup
|
|
692
|
+
begin
|
|
693
|
+
progress&.note("Cleaning temporary load metadata...")
|
|
694
|
+
cleanup_load_metadata(
|
|
695
|
+
quoted_temporary_label, quoted_temporary_id, initial_batch_size, total_nodes, progress
|
|
696
|
+
)
|
|
697
|
+
rescue Error => error
|
|
698
|
+
cleanup_error ||= error
|
|
699
|
+
end
|
|
404
700
|
end
|
|
701
|
+
|
|
702
|
+
raise cleanup_error if original_error.nil? && cleanup_error
|
|
405
703
|
end
|
|
406
704
|
|
|
407
|
-
|
|
705
|
+
progress.finish("Loaded: #{loaded_nodes} nodes, #{loaded_relationships} relationships")
|
|
408
706
|
nil
|
|
707
|
+
ensure
|
|
708
|
+
progress&.close
|
|
409
709
|
end
|
|
410
710
|
|
|
711
|
+
|
|
411
712
|
private
|
|
412
713
|
|
|
413
714
|
def run_and_convert(runner, query, data)
|
|
@@ -535,7 +836,11 @@ module Neo4jBolt
|
|
|
535
836
|
version.to_s.split(".").first.to_i >= 5 ? "elementId" : "id"
|
|
536
837
|
end
|
|
537
838
|
|
|
538
|
-
def parse_dump(io, node_batches, relationship_batches)
|
|
839
|
+
def parse_dump(io, node_batches, relationship_batches, progress = nil)
|
|
840
|
+
node_count = 0
|
|
841
|
+
relationship_count = 0
|
|
842
|
+
progress&.update("Reading dump: 0 nodes, 0 relationships", force: true)
|
|
843
|
+
|
|
539
844
|
io.each_line.with_index(1) do |line, line_number|
|
|
540
845
|
line = line.strip
|
|
541
846
|
next if line.empty?
|
|
@@ -547,16 +852,108 @@ module Neo4jBolt
|
|
|
547
852
|
if kind == "n"
|
|
548
853
|
labels = value.fetch("labels")
|
|
549
854
|
node_batches[labels.sort] << value
|
|
855
|
+
node_count += 1
|
|
550
856
|
else
|
|
551
857
|
relationship_batches[value.fetch("type")] << value
|
|
858
|
+
relationship_count += 1
|
|
859
|
+
end
|
|
860
|
+
if ((node_count + relationship_count) % LOAD_PROGRESS_STEP).zero?
|
|
861
|
+
progress&.update("Reading dump: #{node_count} nodes, #{relationship_count} relationships")
|
|
552
862
|
end
|
|
553
863
|
rescue JSON::ParserError, KeyError => error
|
|
554
864
|
raise Error, "Invalid dump entry on line #{line_number}: #{error.message}"
|
|
555
865
|
end
|
|
866
|
+
|
|
867
|
+
progress&.finish("Read dump: #{node_count} nodes, #{relationship_count} relationships")
|
|
868
|
+
[node_count, relationship_count]
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def adaptive_each_slice(items, batch_size, progress, kind)
|
|
872
|
+
offset = 0
|
|
873
|
+
while offset < items.size
|
|
874
|
+
slice = items.slice(offset, [batch_size, items.size - offset].min)
|
|
875
|
+
begin
|
|
876
|
+
yield slice, batch_size
|
|
877
|
+
offset += slice.size
|
|
878
|
+
rescue Error => error
|
|
879
|
+
raise unless load_batch_memory_error?(error) && slice.size > 1
|
|
880
|
+
|
|
881
|
+
reduced_batch_size = [slice.size / 2, 1].max
|
|
882
|
+
batch_size = [batch_size, reduced_batch_size].min
|
|
883
|
+
progress&.note(
|
|
884
|
+
"Neo4j rejected a #{kind} batch of #{slice.size} for transaction memory; " \
|
|
885
|
+
"retrying with batches of #{batch_size}.",
|
|
886
|
+
level: :warning
|
|
887
|
+
)
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
batch_size
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def load_batch_memory_error?(error)
|
|
894
|
+
upstream = error.cause
|
|
895
|
+
return false unless upstream
|
|
896
|
+
|
|
897
|
+
LOAD_MEMORY_ERROR_CODES.include?(upstream.respond_to?(:code) ? upstream.code : nil) ||
|
|
898
|
+
LOAD_MEMORY_GQL_STATUSES.include?(upstream.respond_to?(:gql_status) ? upstream.gql_status : nil)
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def wait_for_load_index(index_name, progress)
|
|
902
|
+
loop do
|
|
903
|
+
row = neo4j_query_expect_one(<<~CYPHER, name: index_name)
|
|
904
|
+
SHOW INDEXES YIELD name, state, populationPercent
|
|
905
|
+
WHERE name = $name
|
|
906
|
+
RETURN state, populationPercent
|
|
907
|
+
CYPHER
|
|
908
|
+
state = row["state"]
|
|
909
|
+
population = row["populationPercent"] || 0
|
|
910
|
+
if state == "ONLINE"
|
|
911
|
+
progress&.finish("Temporary relationship lookup index ready.")
|
|
912
|
+
return
|
|
913
|
+
end
|
|
914
|
+
raise Error, "Temporary load index #{index_name} entered state #{state}." if state == "FAILED"
|
|
915
|
+
|
|
916
|
+
progress&.update("Building temporary relationship lookup index: #{population.to_i}%")
|
|
917
|
+
sleep 0.25
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def cleanup_load_metadata(quoted_label, quoted_id, batch_size, total_nodes, progress)
|
|
922
|
+
cleaned_nodes = 0
|
|
923
|
+
loop do
|
|
924
|
+
begin
|
|
925
|
+
count = neo4j_query_expect_one(<<~CYPHER, limit: batch_size)["count_n"]
|
|
926
|
+
MATCH (n:#{quoted_label})
|
|
927
|
+
WITH n LIMIT $limit
|
|
928
|
+
REMOVE n.#{quoted_id}
|
|
929
|
+
REMOVE n:#{quoted_label}
|
|
930
|
+
RETURN count(n) AS count_n
|
|
931
|
+
CYPHER
|
|
932
|
+
break if count.zero?
|
|
933
|
+
|
|
934
|
+
cleaned_nodes += count
|
|
935
|
+
progress&.update("Cleaning temporary load metadata: #{cleaned_nodes}/#{total_nodes} nodes")
|
|
936
|
+
rescue Error => error
|
|
937
|
+
raise unless load_batch_memory_error?(error) && batch_size > 1
|
|
938
|
+
|
|
939
|
+
batch_size = [batch_size / 2, 1].max
|
|
940
|
+
progress&.note(
|
|
941
|
+
"Neo4j rejected a cleanup batch for transaction memory; retrying with batches of #{batch_size}.",
|
|
942
|
+
level: :warning
|
|
943
|
+
)
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
progress&.finish("Temporary load metadata removed.")
|
|
556
947
|
end
|
|
557
948
|
|
|
558
|
-
def
|
|
559
|
-
|
|
949
|
+
def progress_message(action, nodes, relationships, total_nodes, total_relationships, batch_size: nil)
|
|
950
|
+
total = total_nodes + total_relationships
|
|
951
|
+
completed = nodes + relationships
|
|
952
|
+
percent = total.zero? ? 100 : (completed * 100 / total)
|
|
953
|
+
message = "#{action}: #{nodes}/#{total_nodes} nodes, " \
|
|
954
|
+
"#{relationships}/#{total_relationships} relationships (#{percent}%)"
|
|
955
|
+
message += " [batch #{batch_size}]" if batch_size
|
|
956
|
+
message
|
|
560
957
|
end
|
|
561
958
|
|
|
562
959
|
def log_query(query, data)
|