partition_gardener 0.3.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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.md +21 -0
- data/README.md +203 -0
- data/SECURITY.md +27 -0
- data/docs/THIRD_PARTY_LICENSE_MANIFEST.tsv +60 -0
- data/docs/application_contract.md +82 -0
- data/docs/audit_reference.md +125 -0
- data/docs/background_job.md +161 -0
- data/docs/cli.md +71 -0
- data/docs/configuration.md +261 -0
- data/docs/cutover.md +180 -0
- data/docs/decision_flow.md +154 -0
- data/docs/host_testing.md +91 -0
- data/docs/monitoring.md +110 -0
- data/docs/naming.md +71 -0
- data/docs/operations.md +131 -0
- data/docs/partition_landscape.md +323 -0
- data/docs/pg_party_recipe.md +44 -0
- data/docs/related_postgres_tooling.md +195 -0
- data/docs/retention.md +65 -0
- data/docs/schemas/partition_garden.schema.json +114 -0
- data/docs/schemas/plan_report.schema.json +82 -0
- data/docs/tooling_split.md +79 -0
- data/exe/partition_gardener +6 -0
- data/lib/partition_gardener/active_record_run_record_store.rb +1 -0
- data/lib/partition_gardener/advisory_lock.rb +48 -0
- data/lib/partition_gardener/archive_retention.rb +88 -0
- data/lib/partition_gardener/audit.rb +93 -0
- data/lib/partition_gardener/blank.rb +13 -0
- data/lib/partition_gardener/cli.rb +190 -0
- data/lib/partition_gardener/config_document.rb +240 -0
- data/lib/partition_gardener/configuration.rb +102 -0
- data/lib/partition_gardener/connection.rb +260 -0
- data/lib/partition_gardener/date_bucket.rb +117 -0
- data/lib/partition_gardener/date_calendar.rb +65 -0
- data/lib/partition_gardener/date_range_maintenance.rb +297 -0
- data/lib/partition_gardener/default_partition.rb +54 -0
- data/lib/partition_gardener/executor.rb +324 -0
- data/lib/partition_gardener/gap_detection.rb +59 -0
- data/lib/partition_gardener/hash_routing.rb +70 -0
- data/lib/partition_gardener/layout/calendar_year.rb +28 -0
- data/lib/partition_gardener/layout/integer_window.rb +28 -0
- data/lib/partition_gardener/layout/sliding_window.rb +28 -0
- data/lib/partition_gardener/layout/three_area.rb +27 -0
- data/lib/partition_gardener/layout/zone_segments.rb +62 -0
- data/lib/partition_gardener/lock_not_acquired.rb +3 -0
- data/lib/partition_gardener/maintenance_backend.rb +73 -0
- data/lib/partition_gardener/memory_run_record_store.rb +19 -0
- data/lib/partition_gardener/migration/hot_switch_concern.rb +445 -0
- data/lib/partition_gardener/missing_conflict_index.rb +3 -0
- data/lib/partition_gardener/naming.rb +29 -0
- data/lib/partition_gardener/orphaned_rebalance_staging.rb +3 -0
- data/lib/partition_gardener/pg_connection.rb +94 -0
- data/lib/partition_gardener/plan.rb +49 -0
- data/lib/partition_gardener/plan_applier.rb +289 -0
- data/lib/partition_gardener/plan_diff.rb +51 -0
- data/lib/partition_gardener/plan_report.rb +95 -0
- data/lib/partition_gardener/planner.rb +21 -0
- data/lib/partition_gardener/predicate.rb +85 -0
- data/lib/partition_gardener/premake_monthly_maintenance.rb +44 -0
- data/lib/partition_gardener/rails.rb +12 -0
- data/lib/partition_gardener/registry.rb +84 -0
- data/lib/partition_gardener/run_failed.rb +10 -0
- data/lib/partition_gardener/run_metrics.rb +65 -0
- data/lib/partition_gardener/run_record.rb +76 -0
- data/lib/partition_gardener/sql_run_record_store.rb +106 -0
- data/lib/partition_gardener/stdlib_extensions.rb +15 -0
- data/lib/partition_gardener/strategy/composite.rb +27 -0
- data/lib/partition_gardener/strategy/cursor_columns.rb +18 -0
- data/lib/partition_gardener/strategy/date_range.rb +303 -0
- data/lib/partition_gardener/strategy/hash_branches.rb +161 -0
- data/lib/partition_gardener/strategy/integer_range.rb +261 -0
- data/lib/partition_gardener/strategy/list_split.rb +125 -0
- data/lib/partition_gardener/strategy/requires_default_partition.rb +19 -0
- data/lib/partition_gardener/strategy.rb +26 -0
- data/lib/partition_gardener/templates.rb +373 -0
- data/lib/partition_gardener/unmoved_rows_remaining.rb +15 -0
- data/lib/partition_gardener/version.rb +3 -0
- data/lib/partition_gardener.rb +215 -0
- data/sig/partition_gardener.rbs +19 -0
- metadata +367 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
class GapDetection
|
|
3
|
+
Gap = Data.define(:range_start, :range_end, :message)
|
|
4
|
+
|
|
5
|
+
def self.call(table_name, config: Registry.find_by_table_name(table_name))
|
|
6
|
+
new(table_name, config: config).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(table_name, config: nil)
|
|
10
|
+
@table_name = table_name
|
|
11
|
+
@config = config || Registry.find_by_table_name(table_name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
return [] unless @config
|
|
16
|
+
|
|
17
|
+
strategy = Strategy.for(@config)
|
|
18
|
+
return [] unless range_layout?(strategy)
|
|
19
|
+
|
|
20
|
+
segments = strategy.attached_tail_segments
|
|
21
|
+
return [] if segments.empty?
|
|
22
|
+
|
|
23
|
+
sorted = segments.sort_by { |segment| [segment.range_start.to_s, segment.name] }
|
|
24
|
+
gaps = []
|
|
25
|
+
|
|
26
|
+
sorted.each_cons(2) do |left, right|
|
|
27
|
+
left_end = normalize_range_end(left.range_end)
|
|
28
|
+
right_start = right.range_start
|
|
29
|
+
next if left_end == right_start
|
|
30
|
+
|
|
31
|
+
gaps << Gap.new(
|
|
32
|
+
range_start: left_end,
|
|
33
|
+
range_end: right_start,
|
|
34
|
+
message: "uncovered range between #{left.name} and #{right.name} (#{left_end}..#{right_start})"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless sorted.any? { |segment| segment.range_end == :max }
|
|
39
|
+
gaps << Gap.new(
|
|
40
|
+
range_start: sorted.last.range_end,
|
|
41
|
+
range_end: :max,
|
|
42
|
+
message: "no attached tail partition extends to MAXVALUE"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
gaps
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def range_layout?(strategy)
|
|
52
|
+
strategy.is_a?(Strategy::DateRange) || strategy.is_a?(Strategy::IntegerRange)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_range_end(range_end)
|
|
56
|
+
(range_end == :max) ? nil : range_end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module HashRouting
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def collect_bucket_counts(config)
|
|
6
|
+
table_name = config[:table_name]
|
|
7
|
+
modulus = config.fetch(:hash_modulus, Strategy::HashBranches::DEFAULT_MODULUS)
|
|
8
|
+
bucket_counts = Hash.new(0)
|
|
9
|
+
|
|
10
|
+
Connection.attached_partitions(table_name).each do |partition|
|
|
11
|
+
next if partition.default
|
|
12
|
+
|
|
13
|
+
remainder = remainder_from_partition(partition, table_name)
|
|
14
|
+
next if remainder.nil?
|
|
15
|
+
|
|
16
|
+
bucket_counts[remainder] += Connection.count_rows_in_partition_table(partition.name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
default_name = Naming.default_partition_name(table_name)
|
|
20
|
+
if Connection.partition_exists?(default_name) && Connection.partition_attached?(table_name, default_name)
|
|
21
|
+
counts_in_default(default_name, config[:partition_key_column], modulus).each do |remainder, count|
|
|
22
|
+
bucket_counts[remainder] += count
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
bucket_counts
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remainder_from_partition(partition, table_name)
|
|
30
|
+
if partition.range_start.is_a?(Hash) && partition.range_start.key?(:remainder)
|
|
31
|
+
return partition.range_start[:remainder]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
match = partition.name.match(/^#{Regexp.escape(table_name)}_[ha]_(\d+)$/)
|
|
35
|
+
return match[1].to_i if match
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def counts_in_default(partition_name, partition_key_column, modulus)
|
|
41
|
+
connection = Connection.connection
|
|
42
|
+
column = connection.quote_column_name(partition_key_column)
|
|
43
|
+
remainder_sql = remainder_sql_expression(column, modulus, connection)
|
|
44
|
+
|
|
45
|
+
sql = <<~SQL
|
|
46
|
+
SELECT #{remainder_sql} AS remainder,
|
|
47
|
+
COUNT(*)::int AS row_count
|
|
48
|
+
FROM #{Connection.quoted_table(partition_name)}
|
|
49
|
+
GROUP BY 1
|
|
50
|
+
SQL
|
|
51
|
+
|
|
52
|
+
connection.execute(sql).each_with_object({}) do |row, counts|
|
|
53
|
+
counts[row["remainder"].to_i] = row["row_count"].to_i
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def remainder_sql_expression(quoted_column, modulus, connection)
|
|
58
|
+
if integer_column?(quoted_column)
|
|
59
|
+
"mod(abs(hashint8extended(#{quoted_column}::bigint, 0)), #{modulus})"
|
|
60
|
+
else
|
|
61
|
+
"mod(abs(hashtextextended(#{quoted_column}::text, 0)), #{modulus})"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def integer_column?(quoted_column)
|
|
66
|
+
normalized = quoted_column.delete('"').downcase
|
|
67
|
+
normalized == "id" || normalized.end_with?("_id")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Layout
|
|
3
|
+
class CalendarYear
|
|
4
|
+
DEFAULT_ACTIVE_YEARS = Strategy::DateRange::DEFAULT_ACTIVE_YEARS
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def active_end(active_start:, active_years: DEFAULT_ACTIVE_YEARS)
|
|
8
|
+
DateCalendar.add_years(active_start, active_years)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build_segments(config:, active_start:, active_end:, hot_years:)
|
|
12
|
+
strategy = Strategy::DateRange.new(config.merge(bucket: :year))
|
|
13
|
+
buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
|
|
14
|
+
|
|
15
|
+
ZoneSegments.build_filler_and_hot_segments(
|
|
16
|
+
table_name: config[:table_name],
|
|
17
|
+
buckets: buckets,
|
|
18
|
+
hot_buckets: hot_years,
|
|
19
|
+
active_start: active_start,
|
|
20
|
+
active_end: active_end,
|
|
21
|
+
hot_bucket_name: config[:partition_name_format],
|
|
22
|
+
bucket_end: ->(bucket) { DateCalendar.beginning_of_year(DateCalendar.next_year(bucket)) }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Layout
|
|
3
|
+
class IntegerWindow
|
|
4
|
+
DEFAULT_ACTIVE_ID_WIDTH = Strategy::IntegerRange::DEFAULT_ACTIVE_ID_WIDTH
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def active_end(active_start:, active_id_width: DEFAULT_ACTIVE_ID_WIDTH)
|
|
8
|
+
active_start + active_id_width
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build_segments(config:, active_start:, active_end:, hot_buckets:)
|
|
12
|
+
strategy = Strategy::IntegerRange.new(config)
|
|
13
|
+
buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
|
|
14
|
+
|
|
15
|
+
ZoneSegments.build_filler_and_hot_segments(
|
|
16
|
+
table_name: config[:table_name],
|
|
17
|
+
buckets: buckets,
|
|
18
|
+
hot_buckets: hot_buckets,
|
|
19
|
+
active_start: active_start,
|
|
20
|
+
active_end: active_end,
|
|
21
|
+
hot_bucket_name: strategy.method(:hot_bucket_partition_name),
|
|
22
|
+
bucket_end: ->(bucket) { [bucket + strategy.send(:current_band_size), active_end].min }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Layout
|
|
3
|
+
class SlidingWindow
|
|
4
|
+
class << self
|
|
5
|
+
def active_end(active_start:, bucket: :month, active_span: nil)
|
|
6
|
+
span = active_span || DateBucket.default_active_span(bucket)
|
|
7
|
+
DateBucket.add_buckets(active_start, span, bucket)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def build_segments(config:, active_start:, active_end:, hot_months:)
|
|
11
|
+
strategy = Strategy::DateRange.new(config)
|
|
12
|
+
buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
|
|
13
|
+
bucket = config.fetch(:bucket, :month)
|
|
14
|
+
|
|
15
|
+
ZoneSegments.build_filler_and_hot_segments(
|
|
16
|
+
table_name: config[:table_name],
|
|
17
|
+
buckets: buckets,
|
|
18
|
+
hot_buckets: hot_months,
|
|
19
|
+
active_start: active_start,
|
|
20
|
+
active_end: active_end,
|
|
21
|
+
hot_bucket_name: config[:partition_name_format],
|
|
22
|
+
bucket_end: ->(bucket_start) { DateBucket.end_of_bucket(bucket_start, bucket) }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Layout
|
|
3
|
+
# Every partitioning strategy targets the same three zones on the key axis:
|
|
4
|
+
#
|
|
5
|
+
# archive — coldest data; fine-grained children, retention-friendly, rarely written
|
|
6
|
+
# current — hot active span; most reads/writes; may contain heat splits inside the zone
|
|
7
|
+
# future — beyond the active window; sparse, less hot than current, not yet archived
|
|
8
|
+
#
|
|
9
|
+
# Strategy plugins (date range, integer range, hash) only define how keys map to bounds.
|
|
10
|
+
# Heatmap collection and hot-bucket policy live on each strategy plugin.
|
|
11
|
+
# Executor cursor moves are identical: ORDER BY partition_key, conflict_key — strategy-agnostic.
|
|
12
|
+
module ThreeArea
|
|
13
|
+
ZONES = %i[archive current future].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def zone_slot_name(table_name, zone)
|
|
17
|
+
case zone
|
|
18
|
+
when :current then Naming.current_partition_name(table_name)
|
|
19
|
+
when :future then Naming.future_partition_name(table_name)
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, "archive zone uses strategy-specific bucket names, not a single slot"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Layout
|
|
3
|
+
module ZoneSegments
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def build_filler_and_hot_segments(table_name:, buckets:, hot_buckets:, active_start:, active_end:, hot_bucket_name:, bucket_end:)
|
|
7
|
+
hot_bucket_set = hot_buckets.to_set
|
|
8
|
+
segments = []
|
|
9
|
+
middle_filler_index = 0
|
|
10
|
+
index = 0
|
|
11
|
+
|
|
12
|
+
while index < buckets.length
|
|
13
|
+
bucket = buckets[index]
|
|
14
|
+
|
|
15
|
+
if hot_bucket_set.include?(bucket)
|
|
16
|
+
segments << Plan::Segment.new(
|
|
17
|
+
name: hot_bucket_name.call(bucket),
|
|
18
|
+
range_start: bucket,
|
|
19
|
+
range_end: bucket_end.call(bucket),
|
|
20
|
+
kind: :hot_bucket
|
|
21
|
+
)
|
|
22
|
+
index += 1
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
run_start = bucket
|
|
27
|
+
while index < buckets.length && !hot_bucket_set.include?(buckets[index])
|
|
28
|
+
index += 1
|
|
29
|
+
end
|
|
30
|
+
run_end = (index < buckets.length) ? buckets[index] : active_end
|
|
31
|
+
|
|
32
|
+
segment_name = if run_start == active_start
|
|
33
|
+
Naming.current_partition_name(table_name)
|
|
34
|
+
else
|
|
35
|
+
middle_filler_index += 1
|
|
36
|
+
if middle_filler_index == 1
|
|
37
|
+
Naming.open_partition_name(table_name)
|
|
38
|
+
else
|
|
39
|
+
"#{table_name}_open_#{middle_filler_index}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
segments << Plan::Segment.new(
|
|
44
|
+
name: segment_name,
|
|
45
|
+
range_start: run_start,
|
|
46
|
+
range_end: run_end,
|
|
47
|
+
kind: :filler
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
segments << Plan::Segment.new(
|
|
52
|
+
name: Naming.future_partition_name(table_name),
|
|
53
|
+
range_start: active_end,
|
|
54
|
+
range_end: :max,
|
|
55
|
+
kind: :future
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
segments
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module MaintenanceBackend
|
|
3
|
+
GARDENER = :gardener
|
|
4
|
+
PG_PARTMAN = :pg_partman
|
|
5
|
+
HYBRID_LAYOUT_ONLY = :hybrid_layout_only
|
|
6
|
+
|
|
7
|
+
class ValidationError < ArgumentError; end
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def normalize(backend)
|
|
12
|
+
backend&.to_sym || GARDENER
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def gardener_owned?(config)
|
|
16
|
+
normalize(config[:maintenance_backend]) != PG_PARTMAN
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def skipped?(config)
|
|
20
|
+
normalize(config[:maintenance_backend]) == PG_PARTMAN
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def hybrid?(config)
|
|
24
|
+
normalize(config[:maintenance_backend]) == HYBRID_LAYOUT_ONLY
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def partman_parent_configured?(table_name)
|
|
28
|
+
Connection.partman_parent_configured?(table_name)
|
|
29
|
+
rescue
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate!(config)
|
|
34
|
+
violations = validation_messages(config)
|
|
35
|
+
return if violations.empty?
|
|
36
|
+
|
|
37
|
+
violations.each do |message|
|
|
38
|
+
if PartitionGardener.configuration.strict_maintenance_backend_validation
|
|
39
|
+
raise ValidationError, message
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
PartitionGardener.configuration.notify(
|
|
43
|
+
"[PartitionGardener] #{message}",
|
|
44
|
+
context: {
|
|
45
|
+
table_name: config[:table_name],
|
|
46
|
+
maintenance_backend: normalize(config[:maintenance_backend])
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validation_messages(config)
|
|
53
|
+
backend = normalize(config[:maintenance_backend])
|
|
54
|
+
table_name = config[:table_name]
|
|
55
|
+
partman_row = partman_parent_configured?(table_name)
|
|
56
|
+
messages = []
|
|
57
|
+
|
|
58
|
+
if backend == PG_PARTMAN && !partman_row
|
|
59
|
+
messages << "#{table_name} maintenance_backend is pg_partman but partman.part_config has no row"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if backend == GARDENER && partman_row
|
|
63
|
+
messages << "#{table_name} is registered for gardener but partman.part_config also lists this parent; pick one maintainer"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if hybrid?(config) && !partman_row
|
|
67
|
+
messages << "#{table_name} hybrid_layout_only expects partman premake on the same parent"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
messages
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
class MemoryRunRecordStore
|
|
3
|
+
def initialize
|
|
4
|
+
@records = {}
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def load(table_name)
|
|
8
|
+
@records[table_name]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def save(table_name, attributes)
|
|
12
|
+
@records[table_name] = attributes
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clear(table_name)
|
|
16
|
+
@records.delete(table_name)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|