legion-apollo 0.3.7 → 0.4.0
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/CHANGELOG.md +19 -1
- data/README.md +1 -0
- data/lib/legion/apollo/helpers/confidence.rb +6 -1
- data/lib/legion/apollo/helpers/similarity.rb +23 -2
- data/lib/legion/apollo/helpers/tag_normalizer.rb +22 -2
- data/lib/legion/apollo/local/graph.rb +218 -54
- data/lib/legion/apollo/local/migrations/003_harden_graph_relationships.rb +23 -0
- data/lib/legion/apollo/local.rb +361 -126
- data/lib/legion/apollo/messages/access_boost.rb +9 -1
- data/lib/legion/apollo/messages/ingest.rb +9 -1
- data/lib/legion/apollo/messages/query.rb +9 -1
- data/lib/legion/apollo/messages/writeback.rb +9 -1
- data/lib/legion/apollo/routes.rb +53 -18
- data/lib/legion/apollo/runners/request.rb +5 -0
- data/lib/legion/apollo/settings.rb +0 -3
- data/lib/legion/apollo/version.rb +1 -1
- data/lib/legion/apollo.rb +234 -36
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7468f022e582731ace40da083285569d57c98e38ef375e5745f43d93e9c9d171
|
|
4
|
+
data.tar.gz: 3d2bb59a17a6060e690c7321fc914d2dc2e2bdd7bb1ec108917bf86fc7f26c72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a47cb6e18c47432ec0f3a15872f52dc193d7253fda94833da1c63d6046ad39bba8894f10dacc1c3472d7ef538157aad39cf1cf9894061623a9f1a162f69a5c16
|
|
7
|
+
data.tar.gz: 3ca7dc35c483089a4b2c6bb0af57b524afe3a3cce571b893a14a4aa01f74fbb01b51b5eb09040389db09a6b5f2516d0bd6d97fc499bf8c912aadc6c3b622a587
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [0.4.0] - 2026-04-02
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Adopt `Legion::Logging::Helper` across the Apollo core, local store, graph layer, message publishers, and helper modules
|
|
7
|
+
- Require `legion-logging >= 1.5.0` for structured helper-based logging support
|
|
8
|
+
- Remove unused top-level Apollo defaults for transport mode and timeout settings
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Start addressing Apollo local lifecycle, merged routing, hydration contract, and route/runtime drift for the 0.4.x line
|
|
12
|
+
- `Apollo.shutdown` now shuts down `Apollo::Local`, and `Apollo::Local.upsert` refreshes expiry and embedding metadata for updated rows
|
|
13
|
+
- `Apollo::Local.hydrate_from_global` now accepts Apollo's `entries` response shape, and `query(scope: :all)` falls back to async global transport when no synchronous backends are available
|
|
14
|
+
- `apollo.enabled` now gates startup, `Apollo::Local.query_by_tags` filters in SQLite before applying limits, and message publish behavior has direct spec coverage
|
|
15
|
+
- Blank local queries now bypass invalid FTS `MATCH ''` calls while still supporting tag-filtered lookups
|
|
16
|
+
- REST query and ingest routes now delegate to `Legion::Apollo`, return `202` for async transport fallback, and stop reporting failed ingests as `201 Created`
|
|
17
|
+
- Apollo and Apollo::Local lifecycle, seeding, and hydration flows are now serialized, while local ingest/upsert keeps base rows and FTS updates inside the same transaction and treats duplicate content races as deterministic deduplication
|
|
18
|
+
- Apollo and Apollo::Local now normalize tag inputs consistently for direct Ruby callers, so ingest, query, and upsert semantics match the route-layer tag contract
|
|
19
|
+
- Apollo graph relationships now reject invalid relation types, deduplicate duplicate semantic edges with a unique index, delete entities transactionally, and traverse larger graphs with batched frontier expansion
|
|
20
|
+
|
|
21
|
+
## [0.3.7] - 2026-03-31
|
|
4
22
|
|
|
5
23
|
### Added
|
|
6
24
|
- `Apollo::Local.promote_to_global(tags:, min_confidence:)` — promotes local entries to Apollo Global
|
data/README.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Apollo
|
|
5
7
|
module Helpers
|
|
6
8
|
# Confidence constants and predicate helpers for Apollo knowledge entries.
|
|
7
9
|
# DB-dependent methods live in lex-apollo; only pure-function logic here.
|
|
8
10
|
module Confidence
|
|
11
|
+
extend Legion::Logging::Helper
|
|
12
|
+
|
|
9
13
|
INITIAL_CONFIDENCE = 0.5
|
|
10
14
|
CORROBORATION_BOOST = 0.15
|
|
11
15
|
CONTRADICTION_PENALTY = 0.20
|
|
@@ -43,7 +47,8 @@ module Legion
|
|
|
43
47
|
return default unless defined?(Legion::Settings) && !Legion::Settings[:apollo].nil?
|
|
44
48
|
|
|
45
49
|
Legion::Settings[:apollo][key] || default
|
|
46
|
-
rescue StandardError
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
handle_exception(e, level: :debug, operation: 'apollo.helpers.confidence.apollo_setting', key: key)
|
|
47
52
|
default
|
|
48
53
|
end
|
|
49
54
|
end
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Apollo
|
|
5
7
|
module Helpers
|
|
6
8
|
# Pure cosine similarity math and match classification for Apollo vectors.
|
|
7
9
|
module Similarity
|
|
10
|
+
extend Legion::Logging::Helper
|
|
11
|
+
|
|
8
12
|
EXACT_MATCH_THRESHOLD = 0.95
|
|
9
13
|
HIGH_SIMILARITY_THRESHOLD = 0.85
|
|
10
14
|
CORROBORATION_THRESHOLD = 0.75
|
|
@@ -12,7 +16,7 @@ module Legion
|
|
|
12
16
|
|
|
13
17
|
module_function
|
|
14
18
|
|
|
15
|
-
def cosine_similarity(vec_a, vec_b) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
19
|
+
def cosine_similarity(vec_a, vec_b) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
16
20
|
return 0.0 if vec_a.nil? || vec_b.nil? || vec_a.empty? || vec_b.empty?
|
|
17
21
|
return 0.0 unless vec_a.size == vec_b.size
|
|
18
22
|
|
|
@@ -30,9 +34,18 @@ module Legion
|
|
|
30
34
|
|
|
31
35
|
denom = Math.sqrt(mag_a) * Math.sqrt(mag_b)
|
|
32
36
|
denom.zero? ? 0.0 : (dot / denom)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
handle_exception(
|
|
39
|
+
e,
|
|
40
|
+
level: :debug,
|
|
41
|
+
operation: 'apollo.helpers.similarity.cosine_similarity',
|
|
42
|
+
vec_a_size: Array(vec_a).size,
|
|
43
|
+
vec_b_size: Array(vec_b).size
|
|
44
|
+
)
|
|
45
|
+
0.0
|
|
33
46
|
end
|
|
34
47
|
|
|
35
|
-
def classify_match(similarity)
|
|
48
|
+
def classify_match(similarity) # rubocop:disable Metrics/MethodLength
|
|
36
49
|
case similarity
|
|
37
50
|
when EXACT_MATCH_THRESHOLD..1.0 then :exact
|
|
38
51
|
when HIGH_SIMILARITY_THRESHOLD...EXACT_MATCH_THRESHOLD then :high
|
|
@@ -40,6 +53,14 @@ module Legion
|
|
|
40
53
|
when RELATED_THRESHOLD...CORROBORATION_THRESHOLD then :related
|
|
41
54
|
else :unrelated
|
|
42
55
|
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
handle_exception(
|
|
58
|
+
e,
|
|
59
|
+
level: :debug,
|
|
60
|
+
operation: 'apollo.helpers.similarity.classify_match',
|
|
61
|
+
similarity: similarity
|
|
62
|
+
)
|
|
63
|
+
:unrelated
|
|
43
64
|
end
|
|
44
65
|
end
|
|
45
66
|
end
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Apollo
|
|
5
7
|
module Helpers
|
|
6
8
|
# Pure-function tag normalization: lowercase, strip invalid chars, dedup, truncate.
|
|
7
9
|
module TagNormalizer
|
|
10
|
+
extend Legion::Logging::Helper
|
|
11
|
+
|
|
8
12
|
MAX_TAG_LENGTH = 64
|
|
9
13
|
MAX_TAGS = 20
|
|
10
14
|
|
|
11
15
|
module_function
|
|
12
16
|
|
|
13
|
-
def normalize(tags)
|
|
17
|
+
def normalize(tags) # rubocop:disable Metrics/MethodLength
|
|
14
18
|
return [] unless tags.is_a?(Array)
|
|
15
19
|
|
|
16
20
|
tags
|
|
@@ -18,14 +22,30 @@ module Legion
|
|
|
18
22
|
.compact
|
|
19
23
|
.uniq
|
|
20
24
|
.first(MAX_TAGS)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
handle_exception(
|
|
27
|
+
e,
|
|
28
|
+
level: :debug,
|
|
29
|
+
operation: 'apollo.helpers.tag_normalizer.normalize',
|
|
30
|
+
tag_count: Array(tags).size
|
|
31
|
+
)
|
|
32
|
+
[]
|
|
21
33
|
end
|
|
22
34
|
|
|
23
|
-
def normalize_one(tag)
|
|
35
|
+
def normalize_one(tag) # rubocop:disable Metrics/MethodLength
|
|
24
36
|
return nil if tag.nil?
|
|
25
37
|
|
|
26
38
|
normalized = tag.to_s.strip.downcase.gsub(/[^a-z0-9_:-]/, '_').squeeze('_')
|
|
27
39
|
normalized = normalized[0, MAX_TAG_LENGTH] if normalized.length > MAX_TAG_LENGTH
|
|
28
40
|
normalized.empty? ? nil : normalized
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
handle_exception(
|
|
43
|
+
e,
|
|
44
|
+
level: :debug,
|
|
45
|
+
operation: 'apollo.helpers.tag_normalizer.normalize_one',
|
|
46
|
+
tag_class: tag.class.to_s
|
|
47
|
+
)
|
|
48
|
+
nil
|
|
29
49
|
end
|
|
30
50
|
end
|
|
31
51
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging'
|
|
3
4
|
require 'time'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
@@ -8,25 +9,37 @@ module Legion
|
|
|
8
9
|
# Entity-relationship graph layer backed by local SQLite tables.
|
|
9
10
|
# Entities are schema-flexible (type + name + domain + JSON attributes).
|
|
10
11
|
# Relationships are directional typed edges between two entities.
|
|
11
|
-
# Graph traversal
|
|
12
|
+
# Graph traversal expands one frontier batch per depth to avoid per-node queries.
|
|
12
13
|
module Graph # rubocop:disable Metrics/ModuleLength
|
|
13
14
|
VALID_RELATION_TYPES = %w[AFFECTS OWNED_BY DEPENDS_ON RELATED_TO].freeze
|
|
14
15
|
|
|
15
16
|
class << self # rubocop:disable Metrics/ClassLength
|
|
17
|
+
include Legion::Logging::Helper
|
|
18
|
+
|
|
16
19
|
# --- Entity CRUD ---
|
|
17
20
|
|
|
18
21
|
def create_entity(type:, name:, domain: nil, attributes: {}) # rubocop:disable Metrics/MethodLength
|
|
19
22
|
now = timestamp
|
|
20
|
-
id = db
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
id = db.transaction do
|
|
24
|
+
db[:local_entities].insert(
|
|
25
|
+
entity_type: type.to_s,
|
|
26
|
+
name: name.to_s,
|
|
27
|
+
domain: domain&.to_s,
|
|
28
|
+
attributes: encode(attributes),
|
|
29
|
+
created_at: now,
|
|
30
|
+
updated_at: now
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
log.info { "Apollo::Local::Graph created entity id=#{id} type=#{type} name=#{name}" }
|
|
28
34
|
{ success: true, id: id }
|
|
29
35
|
rescue Sequel::Error => e
|
|
36
|
+
handle_exception(
|
|
37
|
+
e,
|
|
38
|
+
level: :error,
|
|
39
|
+
operation: 'apollo.local.graph.create_entity',
|
|
40
|
+
entity_type: type,
|
|
41
|
+
name: name
|
|
42
|
+
)
|
|
30
43
|
{ success: false, error: e.message }
|
|
31
44
|
end
|
|
32
45
|
|
|
@@ -34,26 +47,44 @@ module Legion
|
|
|
34
47
|
row = db[:local_entities].where(id: id).first
|
|
35
48
|
return { success: false, error: :not_found } unless row
|
|
36
49
|
|
|
50
|
+
log.debug { "Apollo::Local::Graph found entity id=#{id}" }
|
|
37
51
|
{ success: true, entity: decode_entity(row) }
|
|
38
52
|
rescue Sequel::Error => e
|
|
53
|
+
handle_exception(e, level: :error, operation: 'apollo.local.graph.find_entity', entity_id: id)
|
|
39
54
|
{ success: false, error: e.message }
|
|
40
55
|
end
|
|
41
56
|
|
|
42
|
-
def find_entities_by_type(type:, limit: 50)
|
|
57
|
+
def find_entities_by_type(type:, limit: 50) # rubocop:disable Metrics/MethodLength
|
|
43
58
|
rows = db[:local_entities].where(entity_type: type.to_s).limit(limit).all
|
|
59
|
+
log.debug { "Apollo::Local::Graph found entities type=#{type} count=#{rows.size}" }
|
|
44
60
|
{ success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
|
|
45
61
|
rescue Sequel::Error => e
|
|
62
|
+
handle_exception(
|
|
63
|
+
e,
|
|
64
|
+
level: :error,
|
|
65
|
+
operation: 'apollo.local.graph.find_entities_by_type',
|
|
66
|
+
entity_type: type,
|
|
67
|
+
limit: limit
|
|
68
|
+
)
|
|
46
69
|
{ success: false, error: e.message }
|
|
47
70
|
end
|
|
48
71
|
|
|
49
|
-
def find_entities_by_name(name:, limit: 50)
|
|
72
|
+
def find_entities_by_name(name:, limit: 50) # rubocop:disable Metrics/MethodLength
|
|
50
73
|
rows = db[:local_entities].where(name: name.to_s).limit(limit).all
|
|
74
|
+
log.debug { "Apollo::Local::Graph found entities name=#{name} count=#{rows.size}" }
|
|
51
75
|
{ success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
|
|
52
76
|
rescue Sequel::Error => e
|
|
77
|
+
handle_exception(
|
|
78
|
+
e,
|
|
79
|
+
level: :error,
|
|
80
|
+
operation: 'apollo.local.graph.find_entities_by_name',
|
|
81
|
+
name: name,
|
|
82
|
+
limit: limit
|
|
83
|
+
)
|
|
53
84
|
{ success: false, error: e.message }
|
|
54
85
|
end
|
|
55
86
|
|
|
56
|
-
def update_entity(id:, **fields)
|
|
87
|
+
def update_entity(id:, **fields) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
57
88
|
now = timestamp
|
|
58
89
|
updates = fields.slice(:entity_type, :name, :domain).transform_values(&:to_s)
|
|
59
90
|
updates[:attributes] = encode(fields[:attributes]) if fields.key?(:attributes)
|
|
@@ -62,40 +93,71 @@ module Legion
|
|
|
62
93
|
count = db[:local_entities].where(id: id).update(updates)
|
|
63
94
|
return { success: false, error: :not_found } if count.zero?
|
|
64
95
|
|
|
96
|
+
log.info { "Apollo::Local::Graph updated entity id=#{id}" }
|
|
65
97
|
{ success: true, id: id }
|
|
66
98
|
rescue Sequel::Error => e
|
|
99
|
+
handle_exception(
|
|
100
|
+
e,
|
|
101
|
+
level: :error,
|
|
102
|
+
operation: 'apollo.local.graph.update_entity',
|
|
103
|
+
entity_id: id,
|
|
104
|
+
fields: fields.keys
|
|
105
|
+
)
|
|
67
106
|
{ success: false, error: e.message }
|
|
68
107
|
end
|
|
69
108
|
|
|
70
109
|
def delete_entity(id:)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
count = db[:local_entities].where(id: id).delete
|
|
74
|
-
return { success: false, error: :not_found } if count.zero?
|
|
110
|
+
result = delete_entity_transaction(id)
|
|
111
|
+
return result unless result[:success]
|
|
75
112
|
|
|
76
|
-
{
|
|
113
|
+
log.info { "Apollo::Local::Graph deleted entity id=#{id}" }
|
|
114
|
+
result
|
|
77
115
|
rescue Sequel::Error => e
|
|
116
|
+
handle_exception(e, level: :error, operation: 'apollo.local.graph.delete_entity', entity_id: id)
|
|
78
117
|
{ success: false, error: e.message }
|
|
79
118
|
end
|
|
80
119
|
|
|
81
120
|
# --- Relationship CRUD ---
|
|
82
121
|
|
|
83
122
|
def create_relationship(source_id:, target_id:, relation_type:, attributes: {}) # rubocop:disable Metrics/MethodLength
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
123
|
+
normalized_relation_type = normalize_relation_type(relation_type)
|
|
124
|
+
return invalid_relation_type_error(relation_type) unless normalized_relation_type
|
|
125
|
+
|
|
126
|
+
existing = existing_relationship(source_id, target_id, normalized_relation_type)
|
|
127
|
+
return deduplicated_relationship(existing) if existing
|
|
128
|
+
|
|
129
|
+
id = insert_relationship(source_id, target_id, normalized_relation_type, attributes)
|
|
130
|
+
log.info do
|
|
131
|
+
"Apollo::Local::Graph created relationship id=#{id} source_id=#{source_id} " \
|
|
132
|
+
"target_id=#{target_id} relation_type=#{normalized_relation_type}"
|
|
133
|
+
end
|
|
93
134
|
{ success: true, id: id }
|
|
135
|
+
rescue Sequel::UniqueConstraintViolation => e
|
|
136
|
+
duplicate = handle_duplicate_relationship(source_id, target_id, normalized_relation_type)
|
|
137
|
+
return duplicate if duplicate
|
|
138
|
+
|
|
139
|
+
handle_exception(
|
|
140
|
+
e,
|
|
141
|
+
level: :error,
|
|
142
|
+
operation: 'apollo.local.graph.create_relationship',
|
|
143
|
+
source_id: source_id,
|
|
144
|
+
target_id: target_id,
|
|
145
|
+
relation_type: normalized_relation_type
|
|
146
|
+
)
|
|
147
|
+
{ success: false, error: e.message }
|
|
94
148
|
rescue Sequel::Error => e
|
|
149
|
+
handle_exception(
|
|
150
|
+
e,
|
|
151
|
+
level: :error,
|
|
152
|
+
operation: 'apollo.local.graph.create_relationship',
|
|
153
|
+
source_id: source_id,
|
|
154
|
+
target_id: target_id,
|
|
155
|
+
relation_type: relation_type
|
|
156
|
+
)
|
|
95
157
|
{ success: false, error: e.message }
|
|
96
158
|
end
|
|
97
159
|
|
|
98
|
-
def find_relationships(entity_id:, relation_type: nil, direction: :outbound)
|
|
160
|
+
def find_relationships(entity_id:, relation_type: nil, direction: :outbound) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
99
161
|
ds = case direction
|
|
100
162
|
when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
|
|
101
163
|
when :both then relationship_both_directions(entity_id)
|
|
@@ -103,24 +165,39 @@ module Legion
|
|
|
103
165
|
end
|
|
104
166
|
ds = ds.where(relation_type: relation_type.to_s.upcase) if relation_type
|
|
105
167
|
rows = ds.all
|
|
168
|
+
log.debug do
|
|
169
|
+
"Apollo::Local::Graph found relationships entity_id=#{entity_id} direction=#{direction} " \
|
|
170
|
+
"relation_type=#{relation_type || 'any'} count=#{rows.size}"
|
|
171
|
+
end
|
|
106
172
|
{ success: true, relationships: rows.map { |r| decode_relationship(r) }, count: rows.size }
|
|
107
173
|
rescue Sequel::Error => e
|
|
174
|
+
handle_exception(
|
|
175
|
+
e,
|
|
176
|
+
level: :error,
|
|
177
|
+
operation: 'apollo.local.graph.find_relationships',
|
|
178
|
+
entity_id: entity_id,
|
|
179
|
+
relation_type: relation_type,
|
|
180
|
+
direction: direction
|
|
181
|
+
)
|
|
108
182
|
{ success: false, error: e.message }
|
|
109
183
|
end
|
|
110
184
|
|
|
111
185
|
def delete_relationship(id:)
|
|
112
|
-
count = db[:local_relationships].where(id: id).delete
|
|
186
|
+
count = db.transaction { db[:local_relationships].where(id: id).delete }
|
|
113
187
|
return { success: false, error: :not_found } if count.zero?
|
|
114
188
|
|
|
189
|
+
log.info { "Apollo::Local::Graph deleted relationship id=#{id}" }
|
|
115
190
|
{ success: true, id: id }
|
|
116
191
|
rescue Sequel::Error => e
|
|
192
|
+
handle_exception(e, level: :error, operation: 'apollo.local.graph.delete_relationship', relationship_id: id)
|
|
117
193
|
{ success: false, error: e.message }
|
|
118
194
|
end
|
|
119
195
|
|
|
120
196
|
# --- Graph Traversal ---
|
|
121
197
|
|
|
122
198
|
# Traverse from an entity following edges of the given relation_type.
|
|
123
|
-
# Returns all reachable entities within max_depth hops
|
|
199
|
+
# Returns all reachable entities within max_depth hops by expanding one
|
|
200
|
+
# frontier batch per depth level instead of querying neighbors per node.
|
|
124
201
|
#
|
|
125
202
|
# @param entity_id [Integer] starting entity id
|
|
126
203
|
# @param relation_type [String, nil] filter by edge type (nil = any)
|
|
@@ -141,50 +218,135 @@ module Legion
|
|
|
141
218
|
count: entity_rows.size
|
|
142
219
|
}
|
|
143
220
|
rescue Sequel::Error => e
|
|
221
|
+
handle_exception(
|
|
222
|
+
e,
|
|
223
|
+
level: :error,
|
|
224
|
+
operation: 'apollo.local.graph.traverse',
|
|
225
|
+
entity_id: entity_id,
|
|
226
|
+
relation_type: relation_type,
|
|
227
|
+
depth: depth,
|
|
228
|
+
direction: direction
|
|
229
|
+
)
|
|
144
230
|
{ success: false, error: e.message }
|
|
145
231
|
end
|
|
146
232
|
|
|
147
233
|
private
|
|
148
234
|
|
|
149
|
-
def run_traversal(start_id, rel_filter, max_depth, direction) # rubocop:disable Metrics/MethodLength
|
|
150
|
-
visited = Set.new
|
|
235
|
+
def run_traversal(start_id, rel_filter, max_depth, direction) # rubocop:disable Metrics/MethodLength
|
|
236
|
+
visited = Set.new([start_id])
|
|
151
237
|
frontier = [start_id]
|
|
152
238
|
edge_rows = []
|
|
153
239
|
|
|
154
|
-
|
|
155
|
-
# Pass 0 visits the start node and pushes its neighbors into the next frontier.
|
|
156
|
-
# Passes 1..max_depth each follow one more hop.
|
|
157
|
-
(max_depth + 1).times do
|
|
240
|
+
max_depth.times do
|
|
158
241
|
break if frontier.empty?
|
|
159
242
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
visited.add(current_id)
|
|
165
|
-
neighbors, edges = fetch_neighbors(current_id, rel_filter, direction)
|
|
166
|
-
edge_rows.concat(edges)
|
|
167
|
-
neighbors.each { |n| next_frontier << n unless visited.include?(n) }
|
|
168
|
-
end
|
|
169
|
-
frontier = next_frontier
|
|
243
|
+
rows = fetch_frontier_edges(frontier, rel_filter, direction)
|
|
244
|
+
edge_rows.concat(rows)
|
|
245
|
+
frontier = next_frontier_ids(rows, direction).reject { |neighbor_id| visited.include?(neighbor_id) }.uniq
|
|
246
|
+
frontier.each { |neighbor_id| visited.add(neighbor_id) }
|
|
170
247
|
end
|
|
171
248
|
|
|
172
249
|
[visited.to_a, edge_rows.uniq { |r| r[:id] }]
|
|
173
250
|
end
|
|
174
251
|
|
|
175
|
-
def
|
|
252
|
+
def fetch_frontier_edges(frontier, rel_filter, direction)
|
|
176
253
|
ds = case direction
|
|
177
|
-
when :inbound then db[:local_relationships].where(target_entity_id:
|
|
178
|
-
else db[:local_relationships].where(source_entity_id:
|
|
254
|
+
when :inbound then db[:local_relationships].where(target_entity_id: frontier)
|
|
255
|
+
else db[:local_relationships].where(source_entity_id: frontier)
|
|
179
256
|
end
|
|
180
257
|
ds = ds.where(relation_type: rel_filter) if rel_filter
|
|
181
|
-
|
|
258
|
+
ds.all
|
|
259
|
+
end
|
|
182
260
|
|
|
183
|
-
|
|
184
|
-
|
|
261
|
+
def next_frontier_ids(rows, direction)
|
|
262
|
+
rows.map do |row|
|
|
263
|
+
direction == :inbound ? row[:source_entity_id] : row[:target_entity_id]
|
|
185
264
|
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def normalize_relation_type(relation_type)
|
|
268
|
+
normalized = relation_type.to_s.upcase
|
|
269
|
+
return normalized if VALID_RELATION_TYPES.include?(normalized)
|
|
270
|
+
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def invalid_relation_type_error(relation_type)
|
|
275
|
+
log.warn { "Apollo::Local::Graph rejected invalid relation_type=#{relation_type}" }
|
|
276
|
+
{ success: false, error: :invalid_relation_type }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def existing_relationship(source_id, target_id, relation_type)
|
|
280
|
+
db[:local_relationships].where(
|
|
281
|
+
source_entity_id: source_id,
|
|
282
|
+
target_entity_id: target_id,
|
|
283
|
+
relation_type: relation_type
|
|
284
|
+
).first
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def deduplicated_relationship(existing)
|
|
288
|
+
log.info do
|
|
289
|
+
"Apollo::Local::Graph deduplicated relationship id=#{existing[:id]} " \
|
|
290
|
+
"relation_type=#{existing[:relation_type]}"
|
|
291
|
+
end
|
|
292
|
+
{ success: true, id: existing[:id], mode: :deduplicated }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def insert_relationship(source_id, target_id, relation_type, attributes)
|
|
296
|
+
db.transaction do
|
|
297
|
+
db[:local_relationships].insert(relationship_row(source_id, target_id, relation_type, attributes))
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def relationship_row(source_id, target_id, relation_type, attributes)
|
|
302
|
+
now = timestamp
|
|
303
|
+
{
|
|
304
|
+
source_entity_id: source_id,
|
|
305
|
+
target_entity_id: target_id,
|
|
306
|
+
relation_type: relation_type,
|
|
307
|
+
attributes: encode(attributes),
|
|
308
|
+
created_at: now,
|
|
309
|
+
updated_at: now
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def handle_duplicate_relationship(source_id, target_id, relation_type)
|
|
314
|
+
existing = existing_relationship(source_id, target_id, relation_type)
|
|
315
|
+
return deduplicated_relationship(existing) if existing
|
|
316
|
+
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def delete_entity_transaction(id)
|
|
321
|
+
result = nil
|
|
322
|
+
db.transaction do
|
|
323
|
+
result = existing_entity?(id) ? delete_existing_entity(id) : missing_entity_result
|
|
324
|
+
raise Sequel::Rollback unless result[:success]
|
|
325
|
+
end
|
|
326
|
+
result
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def existing_entity?(id)
|
|
330
|
+
!db[:local_entities].where(id: id).first.nil?
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def delete_existing_entity(id)
|
|
334
|
+
delete_entity_relationships(id)
|
|
335
|
+
delete_entity_row(id)
|
|
336
|
+
{ success: true, id: id }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def missing_entity_result
|
|
340
|
+
{ success: false, error: :not_found }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def delete_entity_relationships(id)
|
|
344
|
+
db[:local_relationships].where(source_entity_id: id).delete
|
|
345
|
+
db[:local_relationships].where(target_entity_id: id).delete
|
|
346
|
+
end
|
|
186
347
|
|
|
187
|
-
|
|
348
|
+
def delete_entity_row(id)
|
|
349
|
+
db[:local_entities].where(id: id).delete
|
|
188
350
|
end
|
|
189
351
|
|
|
190
352
|
def relationship_both_directions(entity_id)
|
|
@@ -206,7 +368,8 @@ module Legion
|
|
|
206
368
|
return '{}' if obj.nil? || obj.empty?
|
|
207
369
|
|
|
208
370
|
Legion::JSON.dump(obj)
|
|
209
|
-
rescue StandardError
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
handle_exception(e, level: :debug, operation: 'apollo.local.graph.encode')
|
|
210
373
|
'{}'
|
|
211
374
|
end
|
|
212
375
|
|
|
@@ -214,7 +377,8 @@ module Legion
|
|
|
214
377
|
return {} if json_str.nil? || json_str.strip.empty?
|
|
215
378
|
|
|
216
379
|
Legion::JSON.parse(json_str, symbolize_names: true)
|
|
217
|
-
rescue StandardError
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
handle_exception(e, level: :debug, operation: 'apollo.local.graph.decode')
|
|
218
382
|
{}
|
|
219
383
|
end
|
|
220
384
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
up do
|
|
5
|
+
run <<~SQL
|
|
6
|
+
DELETE FROM local_relationships
|
|
7
|
+
WHERE id NOT IN (
|
|
8
|
+
SELECT MIN(id)
|
|
9
|
+
FROM local_relationships
|
|
10
|
+
GROUP BY source_entity_id, target_entity_id, relation_type
|
|
11
|
+
)
|
|
12
|
+
SQL
|
|
13
|
+
|
|
14
|
+
run <<~SQL
|
|
15
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_rel_unique
|
|
16
|
+
ON local_relationships (source_entity_id, target_entity_id, relation_type)
|
|
17
|
+
SQL
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
down do
|
|
21
|
+
run 'DROP INDEX IF EXISTS idx_local_rel_unique'
|
|
22
|
+
end
|
|
23
|
+
end
|