dag_me 0.4.2 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 315af993cc34ae379f332dd02f4000562cf48085367a5807d711a564222c84c8
4
- data.tar.gz: 037e69f2e9b9ece91340c0445c6a29ba68daaf43088a59cf6315ffa1c03dfd2b
3
+ metadata.gz: c1012817d36f748b2056aec9ea431941bccab8abb2f9e28bb6e1cf576dc40c87
4
+ data.tar.gz: 18e716d54532f6a25605454dcc1ae08fe91d3e1f6c54dca5e39ede20a2fe8ba2
5
5
  SHA512:
6
- metadata.gz: 8f7e8e4eaa02964bea7ad772989ce878bff46d3275d5e6b69858fa5d3901439d9a808628012c3c6c724153ab4fc9e8fed4458c9bbb34cf153adb3fb7d5e0c891
7
- data.tar.gz: 0ab38c6617793a3e48586e9e5bd58969e2a96980d13736ef90d864141b93816e8241bef552f4cf3f242c95150b40634491489e79f80e202e4a6a705c9fabb080
6
+ metadata.gz: 43b71fef3c469db2e0fbeb5ee2b8831a8ae9553015d813c68cbe72004db61c25ca5154a71563579ca0100e4f7839c0553edaa706037906be9d8b7a84200532c2
7
+ data.tar.gz: 05ef5140aa9a23e43f02414179a55ce9849b8b7809d1eb5923378bda3bacd1e3032ab89a73384081eefcc8a0d69011a3642c8f8e1e4cff7324618fedabed3f2a
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [0.5.0](https://github.com/ClosureTree/dag_me/compare/v0.4.2...v0.5.0) (2026-10-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * keep the dev bundle off activerecord 8.1.4 ([b488d73](https://github.com/ClosureTree/dag_me/commit/b488d735e945ecaef270045e8a53254b9558b8f2))
9
+ * recompute the closure without enumerating paths ([498c9dc](https://github.com/ClosureTree/dag_me/commit/498c9dced5d899de1c3ecd140accdda9f0c5c658))
data/README.md CHANGED
@@ -89,7 +89,7 @@ For a `missions` table:
89
89
  | `mission_dag_edge_insert_apply` | `AFTER INSERT`: incremental closure expansion |
90
90
  | `mission_dag_edge_delete_apply` | `AFTER DELETE`: exact `path_count` decrement + `min_depth` repair |
91
91
  | `mission_dag_node_insert` / `mission_dag_node_delete` | Self-row lifecycle, edge teardown through triggers |
92
- | `mission_dag_rebuild_paths()` / `mission_dag_validate_paths()` | Rebuild from edges / diff against CTE truth |
92
+ | `mission_dag_rebuild_paths()` / `mission_dag_validate_paths()` | Rebuild from edges / diff against a recomputation from edges |
93
93
 
94
94
  Generated by [rails_lens](https://github.com/seuros/rails_lens) from the test app
95
95
  (`make erd`); it reflects the runtime classes dag_me defines, so your own models
@@ -299,7 +299,8 @@ flowchart LR
299
299
  path counts explode combinatorially in dense DAGs).
300
300
  - `min_depth` is the shortest-path length. Deleting an edge triggers exact decremental
301
301
  maintenance: contributions through the deleted edge are subtracted, zero-count pairs
302
- are dropped, and `min_depth` is repaired by fixpoint iteration.
302
+ are dropped, and `min_depth` is repaired: only pairs whose shortest path ran through
303
+ the deleted edge are recomputed, and changes propagate upward until nothing moves.
303
304
  - Edge deletion in dense graphs is the expensive operation, by design. Reads are cheap,
304
305
  inserts are `ancestors(parent) × descendants(child)`, deletes pay for exactness.
305
306
  - Concurrent writers are serialized per graph with `pg_advisory_xact_lock` - two
@@ -310,6 +311,12 @@ flowchart LR
310
311
  an in-flight edge into a cross-tenant graph.
311
312
  - Destroying a node tears down its edges through the triggers (not FK-cascade ordering),
312
313
  so the closure shrinks correctly.
314
+ - `rebuild!` and `validate` recompute the closure from the edges layer by layer (nodes
315
+ grouped by their longest distance from a root), so their cost is polynomial no matter
316
+ how many paths the graph holds. They use temp tables, so they need a writable
317
+ connection, and they raise `DagMe::CycleError` if edges written with triggers disabled
318
+ form a cycle. `validate` runs in its own REPEATABLE READ transaction; inside yours it
319
+ holds SHARE locks on the node and edge tables instead, so writers wait until it ends.
313
320
 
314
321
  ## Rake tasks
315
322
 
@@ -318,6 +325,20 @@ rake dag_me:status # doctor report per network: tables, triggers, funct
318
325
  rake dag_me:rebuild # rebuild every closure (or MODEL=Mission for one)
319
326
  ```
320
327
 
328
+ ## Upgrading
329
+
330
+ The triggers and functions live in your database, so a gem upgrade that changes them
331
+ needs a migration. Function bodies are stamped with `DagMe::DDL::REVISION`, and
332
+ `rake dag_me:status` flags graphs installed by an older one. Generate a refresh per model:
333
+
334
+ ```bash
335
+ rails generate dag_me:refresh Mission
336
+ ```
337
+
338
+ The migration calls `DagMe::DDL.refresh!(Mission)`, which replaces every function body
339
+ with `CREATE OR REPLACE` and reapplies the closure table's storage settings. Tables,
340
+ triggers, and rows are left alone.
341
+
321
342
  ## Testing your app's graphs
322
343
 
323
344
  The gem ships Minitest assertions for host applications:
@@ -343,13 +364,14 @@ All assertions accept `dag:` for named networks.
343
364
 
344
365
  ```bash
345
366
  make up # postgres:18 via docker compose (port 5438)
346
- make check # rubocop + full suite
367
+ make check # dictator lint + full suite
347
368
  ```
348
369
 
349
370
  The suite includes property tests that apply random edge insertions, edge deletions,
350
- and node destructions (single- and multi-tenant) and validate the closure against
351
- recursive-CTE truth after every single operation, plus concurrency tests racing
352
- reverse edges across threads.
371
+ and node destructions (single- and multi-tenant) and validate the closure against a
372
+ recomputation from the edges after every single operation, plus concurrency tests racing
373
+ reverse edges across threads. The recomputation itself is checked against brute-force
374
+ path enumeration on small random graphs.
353
375
 
354
376
  Large graph fixtures are generated, not committed: [vial](https://rubygems.org/gems/vial)
355
377
  compiles `test/vials/*.vial.rb` into deterministic YAML fixtures at test boot
@@ -29,7 +29,7 @@ module DagMe
29
29
  # Ivar peek keeps class load DB-free; composite keys must be declared
30
30
  # before the macro. Array === because AR seeds a BasicObject sentinel.
31
31
  @composite_pk = model.instance_variable_defined?(:@primary_key) &&
32
- Array === model.instance_variable_get(:@primary_key) # rubocop:disable Style/CaseEquality
32
+ Array === model.instance_variable_get(:@primary_key)
33
33
  end
34
34
 
35
35
  def closure?
data/lib/dag_me/ddl.rb CHANGED
@@ -17,7 +17,11 @@ module DagMe
17
17
  # task_dag_node_update -- BEFORE UPDATE on tasks: scope-change guard (scoped only)
18
18
  # task_dag_node_delete -- BEFORE DELETE on tasks: drop edges through triggers
19
19
  # task_dag_rebuild_paths() -- full closure rebuild from edges
20
- # task_dag_validate_paths() -- closure vs recursive-CTE truth diff
20
+ # task_dag_validate_paths() -- stored closure vs closure recomputed from edges
21
+ #
22
+ # rebuild and validate share truth_sql: a layered recomputation that is
23
+ # polynomial in the graph size. Function bodies are stamped with REVISION;
24
+ # refresh! upgrades an installed graph in place.
21
25
  #
22
26
  # Node identity is an ordered column list (Configuration#node_pk_columns):
23
27
  # single-key models get the classic parent_id / child_id / ancestor_id /
@@ -35,6 +39,17 @@ module DagMe
35
39
  # The paths table and its triggers are skipped for maintain: :recursive_cte;
36
40
  # cycle rejection then uses a recursive CTE in the BEFORE INSERT trigger.
37
41
  class DDL
42
+ # Bumped whenever generated function bodies change. Stamped onto the lock
43
+ # function, so `dag_me:status` can spot installs that predate a fix.
44
+ REVISION = 2
45
+
46
+ # Free space left in every closure page. Writes update path_count and
47
+ # min_depth, neither indexed, so with room on the page PostgreSQL can
48
+ # update in place (HOT) and skip both index inserts: on a dense
49
+ # 1,000-node graph that took HOT updates from 27% to 97% and halved
50
+ # mid-graph edge writes.
51
+ PATHS_FILLFACTOR = 70
52
+
38
53
  class << self
39
54
  # Installs / removes the SQL objects for every dag the model declares.
40
55
  def install!(model)
@@ -44,6 +59,12 @@ module DagMe
44
59
  def uninstall!(model)
45
60
  model.dag_configs.each_value { |config| new(config).uninstall! }
46
61
  end
62
+
63
+ # Replaces the function bodies of an installed graph with the current
64
+ # revision. Tables, triggers, and data are left alone.
65
+ def refresh!(model)
66
+ model.dag_configs.each_value { |config| new(config).refresh! }
67
+ end
47
68
  end
48
69
 
49
70
  attr_reader :config
@@ -60,22 +81,47 @@ module DagMe
60
81
  execute_all(uninstall_sql)
61
82
  end
62
83
 
84
+ # Function bodies plus the closure's storage settings. Tables, triggers,
85
+ # and rows are left alone; existing pages gain free space as they are
86
+ # rewritten (VACUUM FULL, or rows updated over time).
87
+ def refresh!
88
+ execute_all(function_sql + storage_sql)
89
+ end
90
+
91
+ def storage_sql
92
+ return [] unless config.closure?
93
+
94
+ ["ALTER TABLE #{config.paths_table} SET (fillfactor = #{PATHS_FILLFACTOR});"]
95
+ end
96
+
63
97
  def install_sql
64
98
  statements = [edges_table_sql]
65
- if config.closure?
66
- statements << paths_table_sql
67
- statements.concat(closure_function_sql)
68
- statements.concat(closure_trigger_sql)
69
- statements << backfill_self_rows_sql
70
- statements << rebuild_function_sql
71
- statements << validate_function_sql
72
- else
73
- statements.concat(cte_function_sql)
74
- statements.concat(cte_trigger_sql)
75
- end
99
+ statements << paths_table_sql if config.closure?
100
+ statements.concat(function_sql)
101
+ statements.concat(config.closure? ? closure_trigger_sql : cte_trigger_sql)
102
+ statements << backfill_self_rows_sql if config.closure?
76
103
  statements
77
104
  end
78
105
 
106
+ # Every function as CREATE OR REPLACE, plus the revision stamp. Re-running
107
+ # it on an installed graph is how refresh! upgrades in place.
108
+ def function_sql
109
+ statements = if config.closure?
110
+ closure_function_sql + [rebuild_function_sql, validate_function_sql]
111
+ else
112
+ cte_function_sql
113
+ end
114
+ statements << revision_comment_sql
115
+ end
116
+
117
+ def revision_comment_sql
118
+ "COMMENT ON FUNCTION #{config.function_ref('lock')}(text) IS '#{self.class.revision_tag}';"
119
+ end
120
+
121
+ def self.revision_tag
122
+ "dag_me ddl revision #{REVISION}"
123
+ end
124
+
79
125
  # Graph#backfill_self_rows! reuses this to repair nodes inserted with
80
126
  # triggers disabled (e.g. Rails fixture loading).
81
127
  def backfill_self_rows_sql
@@ -221,7 +267,7 @@ module DagMe
221
267
  FOREIGN KEY #{tuple(anc_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
222
268
  FOREIGN KEY #{tuple(desc_cols)} REFERENCES #{config.node_table} #{tuple(pk_cols)} ON DELETE CASCADE,
223
269
  PRIMARY KEY (#{list(anc_cols)}, #{list(desc_cols)})
224
- );
270
+ ) WITH (fillfactor = #{PATHS_FILLFACTOR});
225
271
  CREATE INDEX ON #{config.paths_table} (#{list(desc_cols)}, #{list(anc_cols)});
226
272
  SQL
227
273
  end
@@ -315,6 +361,47 @@ module DagMe
315
361
  SQL
316
362
  end
317
363
 
364
+ def x_cols
365
+ pk_cols.map { |c| "x_#{c}" }
366
+ end
367
+
368
+ def y_cols
369
+ pk_cols.map { |c| "y_#{c}" }
370
+ end
371
+
372
+ # Every pair (x, y) whose paths run through the edge `row` (NEW or OLD):
373
+ # x reaches the edge's parent, the edge's child reaches y. `through` is
374
+ # how many x -> y paths use the edge, `via_depth` the shortest of them.
375
+ def through_edge_sql(row)
376
+ x = anc_cols.zip(x_cols).map { |a, c| "a.#{a} AS #{c}" }.join(', ')
377
+ y = desc_cols.zip(y_cols).map { |d, c| "d.#{d} AS #{c}" }.join(', ')
378
+ <<~SQL.chomp
379
+ SELECT #{x}, #{y},
380
+ a.path_count * d.path_count AS through,
381
+ a.min_depth + 1 + d.min_depth AS via_depth
382
+ FROM #{config.paths_table} a
383
+ JOIN #{config.paths_table} d ON #{eq(anc_cols, child_cols, left: 'd', right: row)}
384
+ WHERE #{eq(desc_cols, parent_cols, left: 'a', right: row)}
385
+ SQL
386
+ end
387
+
388
+ # "p.ancestor_id = r.x_id AND p.descendant_id = r.y_id"
389
+ def pair_match(row = 'r', target: 'p')
390
+ "#{eq(anc_cols, x_cols, left: target, right: row)} AND #{eq(desc_cols, y_cols, left: target, right: row)}"
391
+ end
392
+
393
+ # Shortest distance from w's x to w's y over x's remaining out-edges.
394
+ def recomputed_depth_sql(row)
395
+ <<~SQL.chomp
396
+ SELECT MIN(1 + cp.min_depth) AS new_depth
397
+ FROM #{config.edge_table} e
398
+ JOIN #{config.paths_table} cp
399
+ ON #{eq(anc_cols, child_cols, left: 'cp', right: 'e')}
400
+ AND #{eq(desc_cols, y_cols, left: 'cp', right: row)}
401
+ WHERE #{eq(parent_cols, x_cols, left: 'e', right: row)}
402
+ SQL
403
+ end
404
+
318
405
  def edge_insert_apply_sql
319
406
  paths = config.paths_table
320
407
  <<~SQL
@@ -340,75 +427,79 @@ module DagMe
340
427
 
341
428
  def edge_delete_apply_sql
342
429
  p = config.prefix
343
- edges = config.edge_table
344
430
  paths = config.paths_table
345
- x_cols = pk_cols.map { |c| "x_#{c}" }
346
- y_cols = pk_cols.map { |c| "y_#{c}" }
347
- rect_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}," }.join("\n")
431
+ edges = config.edge_table
432
+ work_defs = (col_defs(x_cols) + col_defs(y_cols)).map { |d| " #{d}" }.join(",\n")
348
433
  <<~SQL
349
434
  CREATE OR REPLACE FUNCTION #{config.function_ref('edge_delete_apply')}() RETURNS trigger
350
435
  LANGUAGE plpgsql AS $$
351
436
  BEGIN
352
437
  PERFORM #{config.function_ref('lock')}(#{scope_key_expr('OLD')});
353
438
 
354
- -- The affected rectangle: every pair (x, y) with x reaching OLD's parent
355
- -- and OLD's child reaching y lost `removed` paths through this edge.
356
- -- Multipliers cannot themselves traverse the deleted edge (that would
357
- -- imply a cycle), so pre-decrement closure values are exact here.
358
- CREATE TEMP TABLE IF NOT EXISTS #{p}_delete_rect (
359
- #{rect_defs}
360
- removed numeric NOT NULL,
361
- via_depth integer NOT NULL,
362
- PRIMARY KEY (#{list(x_cols)}, #{list(y_cols)})
439
+ -- Every pair through the deleted edge loses `through` paths: pairs
440
+ -- left with none are dropped, the rest decremented. Multipliers
441
+ -- cannot traverse the deleted edge (that would imply a cycle), so
442
+ -- the closure rows they come from are untouched here.
443
+ WITH r AS MATERIALIZED (
444
+ #{through_edge_sql('OLD')}
445
+ ),
446
+ gone AS (
447
+ DELETE FROM #{paths} p USING r
448
+ WHERE #{pair_match} AND p.path_count <= r.through
449
+ )
450
+ UPDATE #{paths} p
451
+ SET path_count = p.path_count - r.through
452
+ FROM r
453
+ WHERE #{pair_match} AND p.path_count > r.through;
454
+
455
+ -- min_depth repair. Only a surviving pair whose shortest path ran
456
+ -- through the edge (min_depth = via_depth) can get longer:
457
+ -- min_depth(x, y) = min(1 + min_depth(c, y)) over edges x -> c
458
+ -- Pairs that change go to a worklist; their parents' pairs are
459
+ -- recomputed until nothing changes. Values only grow, from below.
460
+ CREATE TEMP TABLE IF NOT EXISTS #{p}_depth_work (
461
+ #{work_defs}
363
462
  ) ON COMMIT DROP;
364
- DELETE FROM #{p}_delete_rect;
365
-
366
- INSERT INTO #{p}_delete_rect (#{list(x_cols)}, #{list(y_cols)}, removed, via_depth)
367
- SELECT #{list(anc_cols, 'a')}, #{list(desc_cols, 'd')},
368
- a.path_count * d.path_count,
369
- a.min_depth + 1 + d.min_depth
370
- FROM #{paths} a
371
- JOIN #{paths} d ON #{eq(anc_cols, child_cols, left: 'd', right: 'OLD')}
372
- WHERE #{eq(desc_cols, parent_cols, left: 'a', right: 'OLD')};
463
+ DELETE FROM #{p}_depth_work;
373
464
 
374
- UPDATE #{paths} p
375
- SET path_count = p.path_count - r.removed
376
- FROM #{p}_delete_rect r
377
- WHERE #{eq(anc_cols, x_cols, left: 'p', right: 'r')}
378
- AND #{eq(desc_cols, y_cols, left: 'p', right: 'r')};
379
-
380
- DELETE FROM #{paths} p
381
- USING #{p}_delete_rect r
382
- WHERE #{eq(anc_cols, x_cols, left: 'p', right: 'r')}
383
- AND #{eq(desc_cols, y_cols, left: 'p', right: 'r')}
384
- AND p.path_count <= 0;
385
-
386
- -- min_depth repair: surviving rectangle pairs may have lost their
387
- -- shortest path. Iterate the recurrence
388
- -- min_depth(x, y) = min(1 + min_depth(c, y)) over edges x -> c reaching y
389
- -- until fixpoint; converges in at most longest-affected-chain steps.
390
- LOOP
465
+ WITH r AS MATERIALIZED (
466
+ #{through_edge_sql('OLD')}
467
+ ),
468
+ raised AS (
391
469
  UPDATE #{paths} p
392
- SET min_depth = fix.new_md
393
- FROM (
394
- SELECT #{list(anc_cols, 's')}, #{list(desc_cols, 's')},
395
- (SELECT MIN(1 + cp.min_depth)
396
- FROM #{edges} e
397
- JOIN #{paths} cp
398
- ON #{eq(anc_cols, child_cols, left: 'cp', right: 'e')}
399
- AND #{eq(desc_cols, desc_cols, left: 'cp', right: 's')}
400
- WHERE #{eq(parent_cols, anc_cols, left: 'e', right: 's')}) AS new_md
401
- FROM #{paths} s
402
- JOIN #{p}_delete_rect r
403
- ON #{eq(x_cols, anc_cols, left: 'r', right: 's')}
404
- AND #{eq(y_cols, desc_cols, left: 'r', right: 's')}
405
- WHERE #{tuple(anc_cols, 's')} <> #{tuple(desc_cols, 's')}
406
- ) fix
407
- WHERE #{eq(anc_cols, anc_cols, left: 'p', right: 'fix')}
408
- AND #{eq(desc_cols, desc_cols, left: 'p', right: 'fix')}
409
- AND fix.new_md IS NOT NULL
410
- AND fix.new_md <> p.min_depth;
411
- EXIT WHEN NOT FOUND;
470
+ SET min_depth = m.new_depth
471
+ FROM r
472
+ CROSS JOIN LATERAL (
473
+ #{recomputed_depth_sql('r')}
474
+ ) m
475
+ WHERE #{pair_match}
476
+ AND p.min_depth = r.via_depth
477
+ AND m.new_depth > p.min_depth
478
+ RETURNING #{list(anc_cols, 'p')}, #{list(desc_cols, 'p')}
479
+ )
480
+ INSERT INTO #{p}_depth_work SELECT * FROM raised;
481
+
482
+ WHILE EXISTS (SELECT 1 FROM #{p}_depth_work) LOOP
483
+ WITH done AS (
484
+ DELETE FROM #{p}_depth_work RETURNING *
485
+ ),
486
+ w AS (
487
+ SELECT DISTINCT #{parent_cols.zip(x_cols).map { |pc, x| "e.#{pc} AS #{x}" }.join(', ')}, #{list(y_cols, 'd')}
488
+ FROM done d
489
+ JOIN #{edges} e ON #{eq(child_cols, x_cols, left: 'e', right: 'd')}
490
+ ),
491
+ raised AS (
492
+ UPDATE #{paths} p
493
+ SET min_depth = m.new_depth
494
+ FROM w
495
+ CROSS JOIN LATERAL (
496
+ #{recomputed_depth_sql('w')}
497
+ ) m
498
+ WHERE #{pair_match('w')}
499
+ AND m.new_depth > p.min_depth
500
+ RETURNING #{list(anc_cols, 'p')}, #{list(desc_cols, 'p')}
501
+ )
502
+ INSERT INTO #{p}_depth_work SELECT * FROM raised;
412
503
  END LOOP;
413
504
 
414
505
  RETURN NULL;
@@ -499,48 +590,139 @@ module DagMe
499
590
  SQL
500
591
  end
501
592
 
502
- def truth_walk_sql
593
+ # Scratch tables for truth_sql: one row per node with its layer, and the
594
+ # closure being computed. Temp, dropped at commit.
595
+ def truth_scratch_sql
596
+ p = config.prefix
597
+ layer_defs = col_defs(pk_cols).map { |d| " #{d}," }.join("\n")
598
+ truth_defs = (col_defs(anc_cols) + col_defs(desc_cols)).map { |d| " #{d}," }.join("\n")
599
+ <<~SQL.chomp
600
+ CREATE TEMP TABLE IF NOT EXISTS #{p}_layer (
601
+ #{layer_defs}
602
+ layer integer NOT NULL,
603
+ PRIMARY KEY (#{list(pk_cols)})
604
+ ) ON COMMIT DROP;
605
+ CREATE INDEX IF NOT EXISTS #{p}_layer_by_layer ON #{p}_layer (layer);
606
+ DELETE FROM #{p}_layer;
607
+ CREATE TEMP TABLE IF NOT EXISTS #{p}_truth (
608
+ #{truth_defs}
609
+ min_depth integer NOT NULL,
610
+ path_count numeric NOT NULL,
611
+ PRIMARY KEY (#{list(desc_cols)}, #{list(anc_cols)})
612
+ ) ON COMMIT DROP;
613
+ DELETE FROM #{p}_truth;
614
+ SQL
615
+ end
616
+
617
+ # Computes the exact closure from the edges into <prefix>_truth, without
618
+ # walking paths one by one (path counts grow exponentially with every
619
+ # diamond, so an enumerating walk does too).
620
+ #
621
+ # Nodes are layered by their longest distance from a root, one set-based
622
+ # INSERT per layer. Every parent of a layer-L node sits in a lower layer,
623
+ # so one aggregate per layer extends the parents' finished rows:
624
+ #
625
+ # path_count(a, v) = sum of path_count(a, u) over edges u -> v
626
+ # min_depth(a, v) = 1 + min of min_depth(a, u) over edges u -> v
627
+ #
628
+ # Nodes left without a layer sit on or below a cycle, which only edges
629
+ # written with triggers disabled can produce: raise DGME1.
630
+ #
631
+ # Expects `depth integer` declared by the enclosing function.
632
+ def truth_sql
633
+ p = config.prefix
634
+ edges = config.edge_table
635
+ node = config.node_table
503
636
  <<~SQL.chomp
504
- WITH RECURSIVE walk(#{list(anc_cols)}, #{list(desc_cols)}, depth) AS (
505
- SELECT #{list(parent_cols)}, #{list(child_cols)}, 1 FROM #{config.edge_table}
506
- UNION ALL
507
- SELECT #{list(anc_cols, 'w')}, #{list(child_cols, 'e')}, w.depth + 1
508
- FROM walk w
509
- JOIN #{config.edge_table} e ON #{eq(parent_cols, desc_cols, left: 'e', right: 'w')}
510
- )
637
+ #{truth_scratch_sql}
638
+
639
+ INSERT INTO #{p}_layer (#{list(pk_cols)}, layer)
640
+ SELECT #{list(pk_cols, 'n')}, 0 FROM #{node} n
641
+ WHERE NOT EXISTS (SELECT 1 FROM #{edges} e WHERE #{eq(child_cols, pk_cols, left: 'e', right: 'n')});
642
+
643
+ depth := 0;
644
+ LOOP
645
+ depth := depth + 1;
646
+ -- children of the previous layer whose parents all have a layer
647
+ INSERT INTO #{p}_layer (#{list(pk_cols)}, layer)
648
+ SELECT DISTINCT #{list(child_cols, 'e')}, depth
649
+ FROM #{p}_layer f
650
+ JOIN #{edges} e ON #{eq(parent_cols, pk_cols, left: 'e', right: 'f')}
651
+ WHERE f.layer = depth - 1
652
+ AND NOT EXISTS (
653
+ SELECT 1 FROM #{edges} w
654
+ WHERE #{eq(child_cols, child_cols, left: 'w', right: 'e')}
655
+ AND NOT EXISTS (SELECT 1 FROM #{p}_layer q WHERE #{eq(pk_cols, parent_cols, left: 'q', right: 'w')})
656
+ );
657
+ EXIT WHEN NOT FOUND;
658
+ END LOOP;
659
+
660
+ IF (SELECT count(*) FROM #{p}_layer) < (SELECT count(*) FROM #{node}) THEN
661
+ RAISE EXCEPTION 'dag_me: % contains a cycle (edges written with triggers disabled?)', '#{edges}'
662
+ USING ERRCODE = '#{SQLSTATE_CYCLE}';
663
+ END IF;
664
+
665
+ INSERT INTO #{p}_truth (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count)
666
+ SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1 FROM #{node};
667
+
668
+ FOR step IN 1 .. depth - 1 LOOP
669
+ INSERT INTO #{p}_truth (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count)
670
+ SELECT #{list(anc_cols, 't')}, #{list(child_cols, 'e')}, MIN(t.min_depth) + 1, SUM(t.path_count)
671
+ FROM #{p}_layer v
672
+ JOIN #{edges} e ON #{eq(child_cols, pk_cols, left: 'e', right: 'v')}
673
+ JOIN #{p}_truth t ON #{eq(desc_cols, parent_cols, left: 't', right: 'e')}
674
+ WHERE v.layer = step
675
+ GROUP BY #{list(anc_cols, 't')}, #{list(child_cols, 'e')};
676
+ END LOOP;
511
677
  SQL
512
678
  end
513
679
 
680
+ # ", n.account_id" (leading comma) or "" - scope taken from the ancestor
681
+ # node, joined as `n` by truth_scope_join.
682
+ def truth_scope_select
683
+ config.scope_columns.map { |c| ", n.#{c}" }.join
684
+ end
685
+
686
+ def truth_scope_join
687
+ return '' unless scoped?
688
+
689
+ " JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 't')}"
690
+ end
691
+
514
692
  def rebuild_function_sql
515
- scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
516
- scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
517
693
  <<~SQL
518
694
  CREATE OR REPLACE FUNCTION #{config.function_ref('rebuild_paths')}() RETURNS void
519
695
  LANGUAGE plpgsql AS $$
696
+ DECLARE
697
+ depth integer;
520
698
  BEGIN
521
699
  LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE ROW EXCLUSIVE MODE;
522
- DELETE FROM #{config.paths_table};
523
700
 
524
- INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
525
- SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1#{scope_column_list} FROM #{config.node_table};
701
+ #{truth_sql.gsub(/^/, ' ')}
526
702
 
527
- #{truth_walk_sql}
703
+ DELETE FROM #{config.paths_table};
528
704
  INSERT INTO #{config.paths_table} (#{list(anc_cols)}, #{list(desc_cols)}, min_depth, path_count#{scope_column_list})
529
- SELECT #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}, MIN(depth), COUNT(*)::numeric#{scope_group}
530
- FROM walk #{scope_join}
531
- GROUP BY #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}#{scope_group};
705
+ SELECT #{list(anc_cols, 't')}, #{list(desc_cols, 't')}, t.min_depth, t.path_count#{truth_scope_select}
706
+ FROM #{config.prefix}_truth t#{truth_scope_join};
532
707
  END;
533
708
  $$;
534
709
  SQL
535
710
  end
536
711
 
712
+ # The truth is computed across several statements. Inside a REPEATABLE
713
+ # READ (or stricter) transaction they share one snapshot; under READ
714
+ # COMMITTED each statement would see fresh data, so writers are held off
715
+ # with SHARE locks for the duration instead.
537
716
  def validate_function_sql
538
- scope_join = scoped? ? "JOIN #{config.node_table} n ON #{eq(pk_cols, anc_cols, left: 'n', right: 'walk')}" : ''
539
- scope_group = config.scope_columns.map { |c| ", n.#{c}" }.join
540
717
  scope_mismatch = scoped? ? "OR #{scope_distinct_expr('s', 't')}" : ''
541
718
  returns = (col_defs(anc_cols, not_null: false) + col_defs(desc_cols, not_null: false))
542
719
  .map { |d| " #{d}," }.join("\n")
543
720
  coalesced = (anc_cols + desc_cols).map { |c| "COALESCE(t.#{c}, s.#{c})" }.join(",\n ")
721
+ truth = if scoped?
722
+ "(SELECT t.*#{truth_scope_select} FROM #{config.prefix}_truth t#{truth_scope_join})"
723
+ else
724
+ "#{config.prefix}_truth"
725
+ end
544
726
  <<~SQL
545
727
  CREATE OR REPLACE FUNCTION #{config.function_ref('validate_paths')}()
546
728
  RETURNS TABLE(
@@ -550,18 +732,22 @@ module DagMe
550
732
  true_min_depth integer,
551
733
  true_path_count numeric
552
734
  )
553
- LANGUAGE sql AS $$
554
- #{truth_walk_sql}, truth AS (
555
- SELECT #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}, MIN(depth) AS min_depth, COUNT(*)::numeric AS path_count#{scope_group}
556
- FROM walk #{scope_join}
557
- GROUP BY #{list(anc_cols, 'walk')}, #{list(desc_cols, 'walk')}#{scope_group}
558
- UNION ALL
559
- SELECT #{list(pk_cols)}, #{list(pk_cols)}, 0, 1::numeric#{scope_column_list} FROM #{config.node_table}
560
- )
735
+ LANGUAGE plpgsql AS $$
736
+ #variable_conflict use_column
737
+ DECLARE
738
+ depth integer;
739
+ BEGIN
740
+ IF current_setting('transaction_isolation') IN ('read committed', 'read uncommitted') THEN
741
+ LOCK TABLE #{config.node_table}, #{config.edge_table} IN SHARE MODE;
742
+ END IF;
743
+
744
+ #{truth_sql.gsub(/^/, ' ')}
745
+
746
+ RETURN QUERY
561
747
  SELECT #{coalesced},
562
748
  s.min_depth, s.path_count,
563
749
  t.min_depth, t.path_count
564
- FROM truth t
750
+ FROM #{truth} t
565
751
  FULL OUTER JOIN #{config.paths_table} s
566
752
  ON #{eq(anc_cols, anc_cols, left: 's', right: 't')}
567
753
  AND #{eq(desc_cols, desc_cols, left: 's', right: 't')}
@@ -570,6 +756,7 @@ module DagMe
570
756
  OR s.min_depth <> t.min_depth
571
757
  OR s.path_count <> t.path_count
572
758
  #{scope_mismatch};
759
+ END;
573
760
  $$;
574
761
  SQL
575
762
  end
data/lib/dag_me/errors.rb CHANGED
@@ -16,7 +16,7 @@ module DagMe
16
16
  class IsolationError < Error; end
17
17
 
18
18
  # Raised by Graph#validate! when the stored closure disagrees with the
19
- # recursive-CTE truth. Carries the offending rows.
19
+ # closure recomputed from the edges. Carries the offending rows.
20
20
  class CorruptionError < Error
21
21
  attr_reader :discrepancies
22
22
 
data/lib/dag_me/graph.rb CHANGED
@@ -51,7 +51,9 @@ module DagMe
51
51
  return self unless config.closure?
52
52
 
53
53
  model.connection_pool.with_connection do |conn|
54
- conn.execute("SELECT #{conn.quote_table_name(config.function_ref('rebuild_paths'))}();")
54
+ DagMe.translate_errors do
55
+ conn.execute("SELECT #{conn.quote_table_name(config.function_ref('rebuild_paths'))}();")
56
+ end
55
57
  end
56
58
  self
57
59
  end
@@ -68,13 +70,19 @@ module DagMe
68
70
  self
69
71
  end
70
72
 
71
- # Rows where the stored closure disagrees with the recursive-CTE truth.
72
- # Empty means healthy.
73
+ # Rows where the stored closure disagrees with the closure recomputed
74
+ # from the edges. Empty means healthy. Raises CycleError when the edges
75
+ # themselves contain a cycle (possible only with triggers disabled).
76
+ # Needs a writable connection: the recomputation uses temp tables.
73
77
  def validate
74
78
  return [] unless config.closure?
75
79
 
76
80
  model.connection_pool.with_connection do |conn|
77
- conn.select_all("SELECT * FROM #{conn.quote_table_name(config.function_ref('validate_paths'))}();").to_a
81
+ DagMe.translate_errors do
82
+ with_stable_snapshot(conn) do
83
+ conn.select_all("SELECT * FROM #{conn.quote_table_name(config.function_ref('validate_paths'))}();").to_a
84
+ end
85
+ end
78
86
  end
79
87
  end
80
88
 
@@ -92,5 +100,16 @@ module DagMe
92
100
 
93
101
  self
94
102
  end
103
+
104
+ private
105
+
106
+ # validate_paths() reads the graph across several statements. Its own
107
+ # REPEATABLE READ transaction gives them one snapshot without blocking
108
+ # writers; inside a caller's transaction the function locks instead.
109
+ def with_stable_snapshot(conn, &)
110
+ return yield if conn.transaction_open?
111
+
112
+ conn.transaction(isolation: :repeatable_read, &)
113
+ end
95
114
  end
96
115
  end
@@ -6,6 +6,7 @@ module DagMe
6
6
  class Railtie < Rails::Railtie # :nodoc:
7
7
  generators do
8
8
  require_relative '../generators/dag_me/migration_generator'
9
+ require_relative '../generators/dag_me/refresh_generator'
9
10
  end
10
11
 
11
12
  rake_tasks do
@@ -46,6 +46,7 @@ module DagMe
46
46
  lines.concat(table_checks(model, config))
47
47
  lines.concat(trigger_checks(model, config))
48
48
  lines.concat(function_checks(model, config))
49
+ lines << revision_line(model, installed_revision(model, config))
49
50
  lines << closure_check(model, config)
50
51
  "#{lines.compact.join("\n ")}\n"
51
52
  end
@@ -111,6 +112,31 @@ module DagMe
111
112
  'n.nspname = ANY (current_schemas(false))'
112
113
  end
113
114
 
115
+ # Function bodies carry the DDL revision they were generated from (a
116
+ # comment on the lock function). :current, :outdated, or nil when the
117
+ # lock function is missing (function_checks already reports that).
118
+ def installed_revision(model, config)
119
+ conn = model.connection
120
+ stamps = conn.select_values(<<~SQL)
121
+ SELECT COALESCE(obj_description(p.oid, 'pg_proc'), '') FROM pg_proc p
122
+ JOIN pg_namespace n ON n.oid = p.pronamespace
123
+ WHERE #{namespace_filter(conn, config)}
124
+ AND p.proname = #{conn.quote("#{config.prefix}_lock")}
125
+ SQL
126
+ return nil if stamps.empty?
127
+
128
+ stamps.first == DDL.revision_tag ? :current : :outdated
129
+ end
130
+
131
+ def revision_line(model, revision)
132
+ case revision
133
+ when :current then ok("functions at revision #{DDL::REVISION}")
134
+ when :outdated
135
+ bad("functions predate revision #{DDL::REVISION} - run `rails generate dag_me:refresh #{model.name}` " \
136
+ 'and migrate')
137
+ end
138
+ end
139
+
114
140
  def closure_check(model, config)
115
141
  return ok('closure: not materialized (recursive_cte)') unless config.closure?
116
142
  return nil unless model.connection.table_exists?(config.paths_table)
@@ -123,6 +149,8 @@ module DagMe
123
149
  facade = config.default? ? 'Model.dag' : "Model.dag(:#{config.name})"
124
150
  bad("closure diverged: #{discrepancies.length} rows - run #{facade}.rebuild!")
125
151
  end
152
+ rescue CycleError
153
+ bad("#{config.edge_table} contains a cycle - edges were written with triggers disabled")
126
154
  end
127
155
  end
128
156
  end
@@ -29,7 +29,7 @@ module DagMe
29
29
  "#{klass} should be scoped by #{Array(scope).join(', ')}"
30
30
  end
31
31
 
32
- # Asserts the stored closure agrees with the recursive-CTE truth.
32
+ # Asserts the stored closure agrees with the closure recomputed from the edges.
33
33
  def assert_dag_valid(klass, dag: nil)
34
34
  discrepancies = klass.dag(dag).validate
35
35
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DagMe
4
- VERSION = '0.4.2'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+ require 'rails/generators/active_record'
6
+
7
+ module DagMe
8
+ module Generators
9
+ # rails generate dag_me:refresh Task
10
+ #
11
+ # Upgrades an installed graph's function bodies to the current
12
+ # DagMe::DDL::REVISION. Named after the revision, so later refreshes
13
+ # get their own migration.
14
+ class RefreshGenerator < Rails::Generators::NamedBase
15
+ include Rails::Generators::Migration
16
+
17
+ source_root File.expand_path('templates', __dir__)
18
+
19
+ def create_migration_file
20
+ migration_template 'refresh_dag.rb.erb',
21
+ "db/migrate/refresh_dag_me_r#{revision}_for_#{file_name.pluralize}.rb"
22
+ end
23
+
24
+ def self.next_migration_number(dirname)
25
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
26
+ end
27
+
28
+ private
29
+
30
+ def revision
31
+ DagMe::DDL::REVISION
32
+ end
33
+
34
+ def migration_version
35
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RefreshDagMeR<%= revision %>For<%= class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
4
+ # Replaces dag_me's function bodies with revision <%= revision %>.
5
+ # Tables, triggers, and rows are left alone.
6
+ def up
7
+ DagMe::DDL.refresh!(<%= class_name %>)
8
+ end
9
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dag_me
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -46,6 +46,7 @@ executables: []
46
46
  extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
49
+ - CHANGELOG.md
49
50
  - MIT-LICENSE
50
51
  - README.md
51
52
  - lib/dag_me.rb
@@ -64,7 +65,9 @@ files:
64
65
  - lib/dag_me/test_helper.rb
65
66
  - lib/dag_me/version.rb
66
67
  - lib/generators/dag_me/migration_generator.rb
68
+ - lib/generators/dag_me/refresh_generator.rb
67
69
  - lib/generators/dag_me/templates/install_dag.rb.erb
70
+ - lib/generators/dag_me/templates/refresh_dag.rb.erb
68
71
  homepage: https://github.com/ClosureTree/dag_me
69
72
  licenses:
70
73
  - MIT
@@ -87,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
90
  - !ruby/object:Gem::Version
88
91
  version: '0'
89
92
  requirements: []
90
- rubygems_version: 4.0.10
93
+ rubygems_version: 4.0.20
91
94
  specification_version: 4
92
95
  summary: Multi-parent DAGs for ActiveRecord, powered by PostgreSQL 18+
93
96
  test_files: []