Module: Xolo::Server::Mixins::Changelog
Overview
This is mixed in to Xolo::Server::Title and Xolo::Server::Version, for simplified access to a title’s changelog
Each title has a changelog file that records changes to the title and its versions.
The changelog file is a ‘jsonlines’ file, which is a JSON file containing a single JSON object per line. See jsonlines.org/ for more info. The reason for using jsonlines is that it is easy to append to the file, rather than having to read the whole file into memory, parse it, add a new entry, and write it back.
In this case, each line is a JSON object (ruby Hash) representing a change or an action.
The keys in the hash are:
:time - the time the change was made
:admin - the admin who made the change
:host - the hostname or IP address of the admin
:version - the version number, or nil if the change is to the title
:attrib - the attribute name, or nil if the change is an action
:old - the original value, or nil if the change is an action
:new - the new value, or nil if the change is an action
:action - a description of the action, or nil if the change is to an attribute
The changelog file is stored in the title directory in a file named ‘changelog.json’. The file exists for as long as the title exists. It is backed up when before every event logged to it, in the backup directory in the server’s BACKUPS_DIR.
When a title is deleted, its changelog file is moved to a backup directory before the title directory is deleted, and will remain there until manually removed.
Constant Summary collapse
- TITLE_CHANGELOG_FILENAME =
The change log filename
'changelog.jsonl'
Class Method Summary collapse
-
.backup_file_dir ⇒ Object
When a title is deleted, its changelog is moved to this directory and renamed to ‘<title>_changelog.json’ This is so that the changelog can be accessed after the title is deleted.
-
.changelog_locks ⇒ Concurrent::Hash
A hash of the read-write locks for each title’s changelog file The key is the title name, the value is the Concurrent::ReentrantReadWriteLock instance for that title.
-
.included(includer) ⇒ Object
when this module is included.
Instance Method Summary collapse
-
#backup_changelog ⇒ void
Copy the changelog file to the backup directory.
-
#changelog ⇒ Array<Hash>
the change log for a title.
-
#changelog_backup_file ⇒ Pathname
The path to the backup file for this title’s changelog.
-
#changelog_file ⇒ Pathname
the change log file for a title.
-
#changelog_lock ⇒ Concurrent::ReentrantReadWriteLock
the read-write lock for a title’s changelog file.
-
#delete_changelog ⇒ void
when a title is deleted, make a final entry, then move its changelog to the backup directory.
-
#hostname_from_ip(ip) ⇒ String
get a hostname from an IP address if possible.
-
#log_change(attrib: nil, old_val: nil, new_val: nil, msg: nil) ⇒ void
Log a change by adding an entry to the changelog file for a title or one of its versions.
-
#log_update_changes ⇒ void
Record all changes during an update of a title or version.
-
#note_changes_for_update_and_log ⇒ Hash
At the start of an update, populate the hash for the @changes_for_update attribute with the changes being made.
Class Method Details
.backup_file_dir ⇒ Object
When a title is deleted, its changelog is moved to this directory and renamed to ‘<title>_changelog.json’ This is so that the changelog can be accessed after the title is deleted.
69 70 71 72 73 74 75 |
# File 'lib/xolo/server/mixins/changelog.rb', line 69 def self.backup_file_dir return @backup_file_dir if @backup_file_dir&.exist? @backup_file_dir = Xolo::Server::BACKUPS_DIR + 'changelogs' @backup_file_dir.mkpath unless @backup_file_dir.exist? @backup_file_dir end |
.changelog_locks ⇒ Concurrent::Hash
A hash of the read-write locks for each title’s changelog file The key is the title name, the value is the Concurrent::ReentrantReadWriteLock instance for that title.
Titles and versions use these locks to ensure that only one thread at a time can write to a title’s changelog file.
85 86 87 |
# File 'lib/xolo/server/mixins/changelog.rb', line 85 def self.changelog_locks @changelog_locks ||= Concurrent::Hash.new end |
.included(includer) ⇒ Object
when this module is included
61 62 63 |
# File 'lib/xolo/server/mixins/changelog.rb', line 61 def self.included(includer) Xolo.verbose_include includer, self end |
Instance Method Details
#backup_changelog ⇒ void
This method returns an undefined value.
Copy the changelog file to the backup directory
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/xolo/server/mixins/changelog.rb', line 147 def backup_changelog return unless changelog_file.exist? unless changelog_backup_file.dirname.exist? log_debug 'Creating backup directory for changelogs' changelog_backup_file.dirname.mkpath end log_debug "Backing up changelog for #{title}" if changelog_backup_file.exist? # if deleting the whole title # move aside any previously existing one, appending a timestamp if self.class == Xolo::Server::Title && deleting? changelog_backup_file.rename "#{changelog_backup_file.basename}.#{changelog_backup_file.mtime.strftime('%Y%m%d%H%M%S')}" # otherwise, overwrite the current backup else changelog_backup_file.delete end end changelog_file.pix_cp changelog_backup_file end |
#changelog ⇒ Array<Hash>
the change log for a title
129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/xolo/server/mixins/changelog.rb', line 129 def changelog log_debug "Reading changelog for #{title}" changelog_data = [] if changelog_file.exist? changelog_lock.with_read_lock do changelog_file.read.lines.each { |l| changelog_data << JSON.parse(l, symbolize_names: true) } end end # reverse the order so the most recent change is first changelog_data.reverse end |
#changelog_backup_file ⇒ Pathname
Returns the path to the backup file for this title’s changelog.
105 106 107 |
# File 'lib/xolo/server/mixins/changelog.rb', line 105 def changelog_backup_file @changelog_backup_file ||= Xolo::Server::Mixins::Changelog.backup_file_dir + "#{title}-#{TITLE_CHANGELOG_FILENAME}" end |
#changelog_file ⇒ Pathname
the change log file for a title
99 100 101 |
# File 'lib/xolo/server/mixins/changelog.rb', line 99 def changelog_file @changelog_file ||= Xolo::Server::Title.title_dir(title) + TITLE_CHANGELOG_FILENAME end |
#changelog_lock ⇒ Concurrent::ReentrantReadWriteLock
the read-write lock for a title’s changelog file
115 116 117 118 119 120 121 122 123 |
# File 'lib/xolo/server/mixins/changelog.rb', line 115 def changelog_lock @changelog_lock ||= if Xolo::Server::Mixins::Changelog.changelog_locks[title] Xolo::Server::Mixins::Changelog.changelog_locks[title] else log_debug "Creating changelog lock for #{title}" Xolo::Server::Mixins::Changelog.changelog_locks[title] = Concurrent::ReentrantReadWriteLock.new end end |
#delete_changelog ⇒ void
This method returns an undefined value.
when a title is deleted, make a final entry, then move its changelog to the backup directory
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/xolo/server/mixins/changelog.rb', line 281 def delete_changelog change = { time: Time.now, admin: session[:admin], host: hostname_from_ip(server_app_instance.request.ip), version: nil, action: 'Title Deleted', attrib: nil, old: nil, new: nil } changelog_lock.with_write_lock do changelog_file.pix_append "#{change.to_json}\n" # final backup changelog_backup_file.delete if changelog_backup_file.exist? changelog_file.rename(changelog_backup_file) end end |
#hostname_from_ip(ip) ⇒ String
get a hostname from an IP address if possible
224 225 226 227 228 229 230 231 232 233 |
# File 'lib/xolo/server/mixins/changelog.rb', line 224 def hostname_from_ip(ip) # gethostbbaddr is deprecated, so use Resolv instead # host = Socket.gethostbyaddr(ip.split('.').map(&:to_i).pack('CCCC')).first host = Resolv.getname(ip) host.pix_empty? ? ip : host rescue Resolv::ResolvError ip end |
#log_change(attrib: nil, old_val: nil, new_val: nil, msg: nil) ⇒ void
This method returns an undefined value.
Log a change by adding an entry to the changelog file for a title or one of its versions.
The entry may be for an message, such as ‘Title Created’, or for a change to the value of an attribute.
Provide either a message to log with :msg, or the name of an attribute being changed, with :attrib, and either :old_val, :new_val, or both. (either can be omitted or set to nil, when adding or removing the attribute)
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/xolo/server/mixins/changelog.rb', line 190 def log_change(attrib: nil, old_val: nil, new_val: nil, msg: nil) log_debug "Preparing to log change for #{title}: attrib=#{attrib.inspect}, old_val=#{old_val.inspect}, new_val=#{new_val.inspect}, msg=#{msg.inspect}" raise ArgumentError, 'Must provide attrib: or action:' if !msg && !attrib raise ArgumentError, 'Must provide old: or new: or both with attrib:' if attrib && old_val.nil? && new_val.nil? # if action, attrib, old, and new are ignored attrib, old_val, new_val = nil if msg change = { time: Time.now, admin: session[:admin], host: hostname_from_ip(server_app_instance.request.ip), version: respond_to?(:version) ? version : nil, msg: msg, attrib: attrib, old: old_val, new: new_val } log_debug "Writing to changelog for #{title}" changelog_lock.with_write_lock do backup_changelog changelog_file.pix_append "#{change.to_json}\n" end end |
#log_update_changes ⇒ void
This method returns an undefined value.
Record all changes during an update of a title or version
268 269 270 271 272 273 274 |
# File 'lib/xolo/server/mixins/changelog.rb', line 268 def log_update_changes return unless changes_for_update changes_for_update.each do |attr, vals| log_change attrib: attr, old_val: vals[:old], new_val: vals[:new] end end |
#note_changes_for_update_and_log ⇒ Hash
At the start of an update, populate the hash for the @changes_for_update attribute with the changes being made.
This is run at the start of the update process, and
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/xolo/server/mixins/changelog.rb', line 241 def note_changes_for_update_and_log return unless new_data_for_update changes = {} self.class::ATTRIBUTES.each do |attr, deets| next unless deets[:changelog] new_val = deets[:type] == :time ? Time.parse(new_data_for_update[attr]) : new_data_for_update[attr] old_val = send attr # Don't change arrays to strings! # just sort them to compare new_val_to_compare = new_val.is_a?(Array) ? new_val.sort : new_val old_val_to_compare = old_val.is_a?(Array) ? old_val.sort : old_val next if new_val_to_compare == old_val_to_compare changes[attr] = { old: old_val, new: new_val } end changes end |