sgfa 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,141 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Locking using filesystem locks
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require_relative 'error'
13
+
14
+ module Sgfa
15
+
16
+
17
+ ##########################################################################
18
+ # Lock based on file locks
19
+ class LockFs
20
+
21
+
22
+ ################################
23
+ # Lock state
24
+ #
25
+ # @return [Symbol] :closed, :unlocked, :shared, or :exclusive
26
+ attr_reader :state
27
+
28
+
29
+ ################################
30
+ # Initialize
31
+ def initialize
32
+ @file = nil
33
+ @state = :closed
34
+ end # def initialize
35
+
36
+
37
+ ################################
38
+ # Open lock file
39
+ #
40
+ # @todo Handle exceptions from File.open()
41
+ #
42
+ # @param fnam [String] File name of lock file
43
+ # @return [String] Contents of the lock file
44
+ # @raise [Error::Sanity] if lock already open
45
+ def open(fnam)
46
+ raise Error::Sanity, 'Lock file already open' if @file
47
+ @file = File.open(fnam, 'r', :encoding => 'utf-8')
48
+ @state = :unlocked
49
+ return @file.read
50
+ end # def open()
51
+
52
+
53
+ ################################
54
+ # Close lock file
55
+ #
56
+ # @raise [Error::Sanity] if lock not open
57
+ def close
58
+ raise Error::Sanity, 'Lock file not open' if !@file
59
+ @file.close
60
+ @file = nil
61
+ @state = :closed
62
+ end # def close
63
+
64
+
65
+ ################################
66
+ # Take exclusive lock
67
+ #
68
+ # @raise [Error::Sanity] if lock not open
69
+ def exclusive
70
+ raise Error::Sanity, 'Lock file not open' if !@file
71
+
72
+ if @state == :exclusive
73
+ return
74
+ elsif @state == :shared
75
+ @file.flock(File::LOCK_UN)
76
+ end
77
+
78
+ @file.flock(File::LOCK_EX)
79
+ @state = :exclusive
80
+ end # def exclusive
81
+
82
+
83
+ ################################
84
+ # Take shared lock
85
+ #
86
+ # @raise [Error::Sanity] if lock not open
87
+ def shared
88
+ raise Error::Sanity, 'Lock file not open' if !@file
89
+ return if @state == :shared
90
+ @file.flock(File::LOCK_SH)
91
+ @state = :shared
92
+ end # def shared
93
+
94
+
95
+ ################################
96
+ # Release lock
97
+ #
98
+ # @raise [Error::Sanity] if lock not open
99
+ def unlock
100
+ raise Error::Sanity, 'Lock file not open' if !@file
101
+ return if @state == :unlocked
102
+ @file.flock(File::LOCK_UN)
103
+ @state = :unlocked
104
+ end # def unlock
105
+
106
+
107
+ ################################
108
+ # Run block while holding exclusive lock
109
+ #
110
+ # @param rel [Boolean] Release lock on return
111
+ # @raise (see #exclusive)
112
+ def do_ex(rel=true)
113
+ exclusive
114
+ begin
115
+ ret = yield
116
+ ensure
117
+ unlock if rel
118
+ end
119
+ return ret
120
+ end # def do_ex
121
+
122
+
123
+ ################################
124
+ # Run block while holding shared lock
125
+ #
126
+ # @param rel [Boolean] Release lock on return
127
+ # @riase (see #shared)
128
+ def do_sh(rel=true)
129
+ shared
130
+ begin
131
+ ret = yield
132
+ ensure
133
+ unlock if rel
134
+ end
135
+ return ret
136
+ end # def do_sh
137
+
138
+
139
+ end # class LockFs
140
+
141
+ end # module Sgfa
@@ -0,0 +1,342 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Jacket state using filesystem storage.
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require 'fileutils'
13
+ require 'tempfile'
14
+
15
+ require_relative 'error'
16
+
17
+ module Sgfa
18
+
19
+
20
+ #####################################################################
21
+ # Maintains {Jacket} state using filesystem storage.
22
+ #
23
+ # This stores the current {History} number for a {Jacket} as well as
24
+ # the current revision numbers for each {Entry}. It also maintains
25
+ # for each tag, a date/time ordered list of all {Entry} items where
26
+ # the current revision contains that tag. Finally, it keeps a list of
27
+ # all tags which are present in the {Jacket}.
28
+ #
29
+ # The current revision of each entry is stored in a file named
30
+ # "_state" with each line consisting of a 9-digit current
31
+ # revision (zero padded) followed by newline. The current {History}
32
+ # number is stored on line zero, with each {Entry} on the corresponding
33
+ # line.
34
+ #
35
+ # The list of tags is in a file named "_list" and consists of
36
+ # each tag followed by a newline.
37
+ #
38
+ # Each tag is kept in a seperate file, named with the tag name. Each
39
+ # line consists of a UTC date/time in "YYYY-MM-DD HH:MM:SS" format,
40
+ # followed by a space, and the 9-digit (zero padded) {Entry} number,
41
+ # with a newline. A "_all" tag tracks all {Entry}s.
42
+ #
43
+ class StateFs
44
+
45
+
46
+ # Tag which has all {Entry} items in a {Jacket}
47
+ TagAll = '_all'
48
+
49
+ # File name to store list of all tags
50
+ TagList = '_list'
51
+
52
+ # File name used to store the current {History} and {Entry} revision
53
+ # numbers
54
+ TagState = '_state'
55
+
56
+ # Size (bytes) of each entry revision
57
+ EntrySize = 10
58
+
59
+ # Size (bytes) of each tag date/time and entry listing
60
+ TagSize = 30
61
+
62
+ # Regular expression to get date/time string and entry number
63
+ # from a tag file
64
+ TagReg = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\d{9})$/
65
+
66
+ private_constant :TagAll, :TagList, :TagState, :EntrySize, :TagSize,
67
+ :TagReg
68
+
69
+
70
+ #####################################
71
+ # Create a new state
72
+ #
73
+ # @param path [String] Path to the directory containing the state
74
+ # @raise [Error::Conflict] if path already exists
75
+ def self.create(path)
76
+ begin
77
+ Dir.mkdir(path)
78
+ rescue Errno::EEXIST
79
+ raise Error::Conflict, 'State path already exists'
80
+ end
81
+ FileUtils.touch(File.join(path, TagList))
82
+ File.open(File.join(path, TagState), 'wb'){|fi| fi.write "000000000\n"}
83
+ end # def self.create()
84
+
85
+
86
+ #####################################
87
+ # Initialize new state, optionally opening
88
+ #
89
+ # @param path [String] Path to the directory containing the state
90
+ # @raise (see #open)
91
+ def initialize(path=nil)
92
+ open(path) if path
93
+ end # def initialize()
94
+
95
+
96
+ #####################################
97
+ # Open the state
98
+ #
99
+ # @param path [String] Path to the directory containing the state
100
+ # @raise [Error::NonExistent] if path does not exist
101
+ # @return [StateFs] self
102
+ def open(path)
103
+ fn = File.join(path, TagState)
104
+ begin
105
+ @file = File.open(fn, 'r+b')
106
+ rescue Errno::ENOENT
107
+ raise Error::NonExistent, 'State path does not exist'
108
+ end
109
+ @path = path.dup
110
+ return self
111
+ end # def open()
112
+
113
+
114
+ #####################################
115
+ # Close the state
116
+ #
117
+ # @raise [Error::Sanity] if state is not open
118
+ # @return [StateFs] self
119
+ def close
120
+ raise Error::Sanity, 'State not open' if !@path
121
+ @file.close
122
+ @path = nil
123
+ return self
124
+ end # def close
125
+
126
+
127
+ #####################################
128
+ # Reset to empty state
129
+ #
130
+ # @raise [Error::Sanity] if state is not open
131
+ # @return (see #open)
132
+ def reset
133
+ raise Error::Sanity, 'State not open' if !@path
134
+
135
+ path = @path
136
+ self.close
137
+
138
+ FileUtils.remove_dir(path)
139
+ Dir.mkdir(path)
140
+ FileUtils.touch(File.join(path, TagList))
141
+ File.open(File.join(path, TagState), 'wb'){|fi| fi.write "000000000\n"}
142
+
143
+ return open(path)
144
+ end # def reset
145
+
146
+
147
+ #####################################
148
+ # Set current revision number for an entry
149
+ #
150
+ # @param enum [Integer] Entry number
151
+ # @param rnum [Integer] Current revision number
152
+ # @raise [Error::Sanity] if state is not open
153
+ # @raise [ArgumentError] if enum or rnum is negative
154
+ # @return [StateFs] self
155
+ def set(enum, rnum)
156
+ raise Error::Sanity, 'State not open' if !@path
157
+ if enum < 0 || rnum < 0
158
+ raise ArgumentError, 'Invalid entry/revision numbers'
159
+ end
160
+
161
+ @file.seek(enum*EntrySize, IO::SEEK_SET)
162
+ @file.write("%09d\n" % rnum)
163
+ return self
164
+ end # def set
165
+
166
+
167
+ #####################################
168
+ # Get current revision number for an entry
169
+ #
170
+ # @param enum [Integer] Entry number
171
+ # @return [Integer] Current revision number
172
+ # @raise [Error::Sanity] if state not open
173
+ # @raise [ArgumentError] if enum is negative
174
+ # @raise [Error::NonExistent] if entry number does not exist
175
+ def get(enum)
176
+ raise Error::Sanity, 'State not open' if !@path
177
+ raise ArgumentError, 'Invalid entry number' if enum < 0
178
+
179
+ @file.seek(enum*EntrySize, IO::SEEK_SET)
180
+ res = @file.read(EntrySize)
181
+ raise Error::NonExistent, 'Entry does not exist' if !res || res[0] == "\x00"
182
+ return res.to_i
183
+ end # def get()
184
+
185
+
186
+ #####################################
187
+ # Read list of tags
188
+ #
189
+ # @return [Array] List of strings containing tag names
190
+ # @raise [Error::Sanity] if state not open
191
+ # @raise [Error::Corrupt] if tag list is missing
192
+ def list
193
+ raise Error::Sanity, 'State not open' if !@path
194
+
195
+ ftn = File.join(@path, TagList)
196
+ begin
197
+ txt = File.read(ftn)
198
+ rescue Errno::ENOENT
199
+ raise Error::Corrupt, 'Unable to read tag list'
200
+ end
201
+ return txt.lines.map{|tg| tg.chomp }
202
+ end # def list
203
+
204
+
205
+ #####################################
206
+ # Read entry numbers from a tag
207
+ #
208
+ # @param name [String] Tag name
209
+ # @param offs [Integer] Offset to begin reading
210
+ # @param max [Integer] Maximum number of entries to return
211
+ # @raise [Error::Sanity] if state not open
212
+ # @raise [Error::NonExistent] if tag does not exist
213
+ # @raise [Error::Corrupt] if file format is bad
214
+ # @return [Array] Total number of entries in the tag, and possibly empty
215
+ # array of entry numbers.
216
+ def tag(name, offs, max)
217
+ raise Error::Sanity, 'State not open' if !@path
218
+
219
+ fn = File.join(@path, name)
220
+ begin
221
+ fi = File.open(fn, 'rb')
222
+ rescue Errno::ENOENT
223
+ raise Error::NonExistent, 'Tag does not exist'
224
+ end
225
+
226
+ ents = []
227
+ size = nil
228
+ begin
229
+ size = fi.size / TagSize
230
+ return [size, ents] if( offs > size || max == 0 )
231
+ num = (offs+max > size) ? (size - offs) : max
232
+ fi.seek(((size-offs-num)*TagSize), IO::SEEK_SET)
233
+ num.times do
234
+ ln = fi.read(TagSize)
235
+ ma = TagReg.match(ln)
236
+ raise Error::Corrupt, 'Bad tag format' unless ma
237
+ ents.push( ma[2].to_i )
238
+ end
239
+ ensure
240
+ fi.close
241
+ end
242
+
243
+ return [size, ents]
244
+ end # def tag()
245
+
246
+
247
+ #####################################
248
+ # Update tags based on new {History}
249
+ #
250
+ # @param cng [Hash] Changes in the format returned by {History#next}
251
+ # @raise [Error::Sanity] if state not open
252
+ # @raise [Error::Corrupt] if file format is bad
253
+ # @raise [Error::Corrupt] if list of tags is missing
254
+ # @raise [Error::Corrupt] if existing tag is missing
255
+ # @return [StateFs] self
256
+ def update(cng)
257
+
258
+ # read list of tags
259
+ changed = false
260
+ thash = {}
261
+ self.list.each{|tag| thash[tag] = true }
262
+
263
+ cng.each do |tag, hc|
264
+ cnt = 0
265
+
266
+ # sorted changes
267
+ se = hc.to_a.select{|en, ti| ti }.sort{|aa, bb| aa[1] <=> bb[1] }
268
+
269
+ # files
270
+ fn = File.join(@path, tag)
271
+ if thash[tag]
272
+ begin
273
+ oldf = File.open(fn, 'rb')
274
+ rescue Errno::ENOENT
275
+ raise Error::Corrupt, 'Existing tag is missing'
276
+ end
277
+ else
278
+ oldf = nil
279
+ end
280
+ newf = Tempfile.new('state', @path, :encoding => 'ASCII-8BIT')
281
+
282
+ # merge new into old file
283
+ while oldf && ln = oldf.read(TagSize)
284
+ unless ma = TagReg.match(ln)
285
+ newf.close!
286
+ raise Error::Corrupt, 'Bad tag format'
287
+ end
288
+
289
+ tme = ma[1]
290
+ enum = ma[2].to_i
291
+
292
+ next if hc.has_key?(enum)
293
+
294
+ while se.size > 0 && tme > se[0][1]
295
+ ne, nt = se.shift
296
+ newf.write("%s %09d\n" % [nt, ne])
297
+ cnt += 1
298
+ end
299
+
300
+ newf.write(ln)
301
+ cnt += 1
302
+ end
303
+ oldf.close() if oldf
304
+
305
+ # write out any remaining new
306
+ while ary = se.shift
307
+ ne, nt = ary
308
+ newf.write("%s %09d\n" % [nt, ne])
309
+ cnt += 1
310
+ end
311
+
312
+ # adjust files
313
+ if cnt == 0
314
+ if oldf
315
+ File.unlink(fn)
316
+ thash.delete(tag)
317
+ changed = true
318
+ end
319
+ else
320
+ FileUtils.ln(newf.path, fn, :force => true)
321
+ if !oldf
322
+ thash[tag] = true
323
+ changed = true
324
+ end
325
+ end
326
+ newf.close!
327
+ end
328
+
329
+ # write list of tags
330
+ if changed
331
+ fnl = File.join(@path, TagList)
332
+ File.open(fnl, 'w', :encoding => 'utf-8') do |fi|
333
+ thash.each_key{|tag| fi.puts tag }
334
+ end
335
+ end
336
+
337
+ return self
338
+ end # def update()
339
+
340
+ end # class StateFs
341
+
342
+ end # module Sgfa
@@ -0,0 +1,214 @@
1
+ #
2
+ # Simple Group of Filing Applications
3
+ # Jacket store using filesystem storage.
4
+ #
5
+ # Copyright (C) 2015 by Graham A. Field.
6
+ #
7
+ # See LICENSE.txt for licensing information.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require 'tempfile'
13
+ require 'fileutils'
14
+
15
+ require_relative 'error'
16
+
17
+ module Sgfa
18
+
19
+
20
+ #####################################################################
21
+ # Stores copies of {History}, {Entry}, and attached files that are
22
+ # in a {Jacket} using filesystem storage.
23
+ #
24
+ # Storage and retrieval are based on type (i.e. History, Entry, or
25
+ # attached file) and the item hash. The item hash is generated from
26
+ # the {Jacket} id_hash, the type, and the specific number. This allows
27
+ # for a single store to serve as the repository of multiple {Jacket}s
28
+ # if desired (e.g. using a cloud permanent object store).
29
+ #
30
+ # Each item is stored in a file, organized into directories based on the
31
+ # first two characters of the item hash, a file name consisting of the
32
+ # remaining item hash, dash, and a character indicating the type.
33
+ #
34
+ class StoreFs
35
+
36
+
37
+ #####################################
38
+ # Initialize a new store object, optionally opening
39
+ #
40
+ # @param (see #open)
41
+ # @raise (see #open)
42
+ def initialize(path=nil)
43
+ open(path) if path
44
+ end # def initialize()
45
+
46
+
47
+ #####################################
48
+ # Create a new store
49
+ # @param path [String] Path to the directory containing the store
50
+ # @raise [Error::Conflict] if path already exists
51
+ def self.create(path)
52
+ begin
53
+ Dir.mkdir(path)
54
+ rescue Errno::EEXIST
55
+ raise Error::Conflict, 'Store already exists'
56
+ end
57
+ end # def self.create()
58
+
59
+
60
+ #####################################
61
+ # Open the store
62
+ #
63
+ # @param path [String] Path to the directory containing the store
64
+ # @raise [Error::NonExistent] if path does not exist
65
+ # @return [StoreFs] self
66
+ def open(path)
67
+ raise Error::NonExistent, 'Store does not exist' if !File.directory?(path)
68
+ @path = path
69
+ return self
70
+ end # open()
71
+
72
+
73
+ #####################################
74
+ # Close the store
75
+ #
76
+ # @raise [Error::Sanity] if store is not open
77
+ # @return [StoreFs] self
78
+ def close
79
+ raise Error::Sanity, 'Store is not open' if !@path
80
+ @path = nil
81
+ return self
82
+ end # def close
83
+
84
+
85
+ #####################################
86
+ # File name for an item
87
+ #
88
+ # @param type [Symbol] Type of the item
89
+ # @param item [String] Item hash identifier
90
+ # @raise [NotImplementedError] if type is not valid
91
+ # @return [String] the file name
92
+ def _fn(type, item)
93
+ case type
94
+ when :entry then ext = 'e'
95
+ when :history then ext = 'h'
96
+ when :file then ext = 'f'
97
+ else raise NotImplementedError, 'Invalid item type'
98
+ end
99
+
100
+ return '%s/%s/%s-%s' % [@path, item[0,2], item[2..-1], ext]
101
+ end # def _fn()
102
+ private :_fn
103
+
104
+
105
+ #####################################
106
+ # Get a temp file to use to create an item for storage
107
+ #
108
+ # @note Encoding on the file is set to 'utf-8' by default
109
+ # @raise [Error::Sanity] if store not open
110
+ # @return [Tempfile] the temporary file
111
+ def temp
112
+ raise Error::Sanity, 'Store not open' if !@path
113
+ Tempfile.new('blob', @path, :encoding => 'utf-8')
114
+ end # def temp
115
+
116
+
117
+ #####################################
118
+ # Read an item
119
+ #
120
+ # @param (see #_fn)
121
+ # @raise [NotImplementedError] if type is not valid
122
+ # @raise [Error::Sanity] if store not open
123
+ # @return [File] Item opened in read only format or false if not found
124
+ def read(type, item)
125
+ raise Error::Sanity, 'Store not open' if !@path
126
+
127
+ fn = _fn(type, item)
128
+ begin
129
+ fi = File.open(fn, 'r', :encoding => 'utf-8')
130
+ rescue Errno::ENOENT
131
+ return false
132
+ end
133
+ return fi
134
+ end # def read()
135
+
136
+
137
+ #####################################
138
+ # Write an item
139
+ #
140
+ # If content is a string, it will be written to a file and saved.
141
+ # If content is a file it will be deleted from it's current location.
142
+ #
143
+ # @param (see #_fn)
144
+ # @param cont [File, String] Content to store
145
+ # @raise [Error::Sanity] if store not open
146
+ # @raise [NotImplementedError] if type is not valid
147
+ def write(type, item, cont)
148
+ raise Error::Sanity, 'Store not open' if !@path
149
+ fn = _fn(type, item)
150
+
151
+ if cont.is_a?(String)
152
+ tf = temp
153
+ tf.write cont
154
+ cont = tf
155
+ end
156
+
157
+ begin
158
+ FileUtils.ln(cont.path, fn, :force => true)
159
+ rescue Errno::ENOENT
160
+ Dir.mkdir(File.dirname(fn))
161
+ FileUtils.ln(cont.path, fn)
162
+ end
163
+ if cont.respond_to?( :close! )
164
+ cont.close!
165
+ else
166
+ File.unlink(cont, path)
167
+ cont.close
168
+ end
169
+
170
+ return self
171
+ end # def write()
172
+
173
+
174
+ #####################################
175
+ # Delete an item
176
+ #
177
+ # @param (see #_fn)
178
+ # @raise [NotImplementedError] if type is not valid
179
+ # @raise [Error::Sanity] if store not open
180
+ # @return [Boolean] if item deleted
181
+ def delete(type, item)
182
+ raise Error::Sanity, 'Store not open' if !@path
183
+ fn = _fn(type, item)
184
+
185
+ begin
186
+ File.unlink(fn)
187
+ rescue Errno::ENOENT
188
+ return false
189
+ end
190
+ return true
191
+ end # def delete()
192
+
193
+
194
+ #####################################
195
+ # Get size of an item in bytes
196
+ #
197
+ # @param (see #_fn)
198
+ # @raise [NotImplementedError] if type is not valid
199
+ # @raise [Error::Sanity] if store not open
200
+ # @return [Integer, Boolean] Item size, or false if item does not exist
201
+ def size(type, item)
202
+ fn = _fn(type, item)
203
+ begin
204
+ size = File.size(fn)
205
+ rescue Errno::ENOENT
206
+ return false
207
+ end
208
+ return size
209
+ end # def size()
210
+
211
+
212
+ end # class StoreFs
213
+
214
+ end # module Sgfa