lockfile 1.1.0 → 1.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.
@@ -0,0 +1,518 @@
1
+ unless defined? $__lockfile__
2
+ require 'socket'
3
+ require 'timeout'
4
+ require 'fileutils'
5
+
6
+ class Lockfile
7
+ #--{{{
8
+ VERSION = '1.3.0'
9
+
10
+ class LockError < StandardError; end
11
+ class StolenLockError < LockError; end
12
+ class StackingLockError < LockError; end
13
+ class StatLockError < LockError; end
14
+ class MaxTriesLockError < LockError; end
15
+ class TimeoutLockError < LockError; end
16
+ class NFSLockError < LockError; end
17
+ class UnLockError < LockError; end
18
+
19
+ class SleepCycle < Array
20
+ #--{{{
21
+ attr :min
22
+ attr :max
23
+ attr :range
24
+ attr :inc
25
+ def initialize min, max, inc
26
+ #--{{{
27
+ @min, @max, @inc = Float(min), Float(max), Float(inc)
28
+ @range = @max - @min
29
+ raise RangeError, "max < min" if @max < @min
30
+ raise RangeError, "inc > range" if @inc > @range
31
+ s = @min
32
+ push(s) and s += @inc while(s <= @max)
33
+ self[-1] = @max if self[-1] < @max
34
+ reset
35
+ #--}}}
36
+ end
37
+ def next
38
+ #--{{{
39
+ ret = self[@idx]
40
+ @idx = ((@idx + 1) % self.size)
41
+ ret
42
+ #--}}}
43
+ end
44
+ def reset
45
+ #--{{{
46
+ @idx = 0
47
+ #--}}}
48
+ end
49
+ #--}}}
50
+ end
51
+
52
+ HOSTNAME = Socket::gethostname
53
+
54
+ DEFAULT_RETRIES = nil # maximum number of attempts
55
+ DEFAULT_TIMEOUT = nil # the longest we will try
56
+ DEFAULT_MAX_AGE = 1024 # lockfiles older than this are stale
57
+ DEFAULT_SLEEP_INC = 2 # sleep cycle is this much longer each time
58
+ DEFAULT_MIN_SLEEP = 2 # shortest sleep time
59
+ DEFAULT_MAX_SLEEP = 32 # longest sleep time
60
+ DEFAULT_SUSPEND = 64 # iff we steal a lock wait this long before we go on
61
+ DEFAULT_REFRESH = 8 # how often we touch/validate the lock
62
+ DEFAULT_DONT_CLEAN = false # iff we leave lock files lying around
63
+ DEFAULT_POLL_RETRIES = 16 # this many polls makes one 'try'
64
+ DEFAULT_POLL_MAX_SLEEP = 0.08 # the longest we'll sleep between polls
65
+ DEFAULT_DONT_SWEEP = false # if we cleanup after other process on our host
66
+
67
+ DEFAULT_DEBUG = ENV['LOCKFILE_DEBUG'] || false
68
+
69
+ class << self
70
+ #--{{{
71
+ attr :retries, true
72
+ attr :max_age, true
73
+ attr :sleep_inc, true
74
+ attr :min_sleep, true
75
+ attr :max_sleep, true
76
+ attr :suspend, true
77
+ attr :timeout, true
78
+ attr :refresh, true
79
+ attr :debug, true
80
+ attr :dont_clean, true
81
+ attr :poll_retries, true
82
+ attr :poll_max_sleep, true
83
+ attr :dont_sweep, true
84
+
85
+ def init
86
+ #--{{{
87
+ @retries = DEFAULT_RETRIES
88
+ @max_age = DEFAULT_MAX_AGE
89
+ @sleep_inc = DEFAULT_SLEEP_INC
90
+ @min_sleep = DEFAULT_MIN_SLEEP
91
+ @max_sleep = DEFAULT_MAX_SLEEP
92
+ @suspend = DEFAULT_SUSPEND
93
+ @timeout = DEFAULT_TIMEOUT
94
+ @refresh = DEFAULT_REFRESH
95
+ @dont_clean = DEFAULT_DONT_CLEAN
96
+ @poll_retries = DEFAULT_POLL_RETRIES
97
+ @poll_max_sleep = DEFAULT_POLL_MAX_SLEEP
98
+ @dont_sweep = DEFAULT_DONT_SWEEP
99
+
100
+ @debug = DEFAULT_DEBUG
101
+
102
+ STDOUT.sync = true if @debug
103
+ STDERR.sync = true if @debug
104
+ #--}}}
105
+ end
106
+ #--}}}
107
+ end
108
+ self.init
109
+
110
+ attr :klass
111
+ attr :path
112
+ attr :opts
113
+ attr :locked
114
+ attr :thief
115
+ attr :dirname
116
+ attr :basename
117
+ attr :clean
118
+ attr :retries
119
+ attr :max_age
120
+ attr :sleep_inc
121
+ attr :min_sleep
122
+ attr :max_sleep
123
+ attr :suspend
124
+ attr :refresh
125
+ attr :timeout
126
+ attr :dont_clean
127
+ attr :poll_retries
128
+ attr :poll_max_sleep
129
+ attr :dont_sweep
130
+
131
+ attr :debug, true
132
+
133
+ alias thief? thief
134
+ alias locked? locked
135
+ alias debug? debug
136
+
137
+ def initialize(path, opts = {}, &block)
138
+ #--{{{
139
+ @klass = self.class
140
+ @path = path
141
+ @opts = opts
142
+
143
+ @retries = getopt('retries') || @klass.retries
144
+ @max_age = getopt('max_age') || @klass.max_age
145
+ @sleep_inc = getopt('sleep_inc') || @klass.sleep_inc
146
+ @min_sleep = getopt('min_sleep') || @klass.min_sleep
147
+ @max_sleep = getopt('max_sleep') || @klass.max_sleep
148
+ @suspend = getopt('suspend') || @klass.suspend
149
+ @timeout = getopt('timeout') || @klass.timeout
150
+ @refresh = getopt('refresh') || @klass.refresh
151
+ @dont_clean = getopt('dont_clean') || @klass.dont_clean
152
+ @poll_retries = getopt('poll_retries') || @klass.poll_retries
153
+ @poll_max_sleep = getopt('poll_max_sleep') || @klass.poll_max_sleep
154
+ @dont_sweep = getopt('dont_sweep') || @klass.dont_sweep
155
+
156
+ @debug = getopt('debug') || @klass.debug
157
+
158
+ @sleep_cycle = SleepCycle::new @min_sleep, @max_sleep, @sleep_inc
159
+
160
+ @clean = @dont_clean ? nil : lambda{ File::unlink @path rescue nil }
161
+ @dirname = File::dirname @path
162
+ @basename = File::basename @path
163
+ @thief = false
164
+ @locked = false
165
+
166
+ lock(&block) if block
167
+ #--}}}
168
+ end
169
+ def lock
170
+ #--{{{
171
+ raise StackingLockError, "<#{ @path }> is locked!" if @locked
172
+
173
+ sweep unless @dont_sweep
174
+
175
+ ret = nil
176
+
177
+ attempt do
178
+ begin
179
+ @sleep_cycle.reset
180
+ create_tmplock do |f|
181
+ begin
182
+ Timeout::timeout(@timeout) do
183
+ tmp_path = f.path
184
+ tmp_stat = f.lstat
185
+ n_retries = 0
186
+ trace{ "attempting to lock <#{ @path }>..." }
187
+ begin
188
+ i = 0
189
+ begin
190
+ trace{ "polling attempt <#{ i }>..." }
191
+ begin
192
+ File::link tmp_path, @path
193
+ rescue Errno::ENOENT
194
+ try_again!
195
+ end
196
+ lock_stat = File::lstat @path
197
+ raise StatLockError, "stat's do not agree" unless
198
+ tmp_stat.rdev == lock_stat.rdev and tmp_stat.ino == lock_stat.ino
199
+ trace{ "aquired lock <#{ @path }>" }
200
+ @locked = true
201
+ rescue => e
202
+ i += 1
203
+ unless i >= @poll_retries
204
+ t = [rand(@poll_max_sleep), @poll_max_sleep].min
205
+ trace{ "poll sleep <#{ t }>..." }
206
+ sleep t
207
+ retry
208
+ end
209
+ raise
210
+ end
211
+
212
+ rescue => e
213
+ n_retries += 1
214
+ trace{ "n_retries <#{ n_retries }>" }
215
+ case validlock?
216
+ when true
217
+ raise MaxTriesLockError, "surpased retries <#{ @retries }>" if
218
+ @retries and n_retries >= @retries
219
+ trace{ "found valid lock" }
220
+ sleeptime = @sleep_cycle.next
221
+ trace{ "sleep <#{ sleeptime }>..." }
222
+ sleep sleeptime
223
+ when false
224
+ trace{ "found invalid lock and removing" }
225
+ begin
226
+ File::unlink @path
227
+ @thief = true
228
+ warn "<#{ @path }> stolen by <#{ Process.pid }> at <#{ timestamp }>"
229
+ trace{ "i am a thief!" }
230
+ rescue Errno::ENOENT
231
+ end
232
+ trace{ "suspending <#{ @suspend }>" }
233
+ sleep @suspend
234
+ when nil
235
+ raise MaxTriesLockError, "surpased retries <#{ @retries }>" if
236
+ @retries and n_retries >= @retries
237
+ end
238
+ retry
239
+ end # begin
240
+ end # timeout
241
+ rescue Timeout::Error
242
+ raise TimeoutLockError, "surpassed timeout <#{ @timeout }>"
243
+ end # begin
244
+ end # create_tmplock
245
+
246
+ if block_given?
247
+ stolen = false
248
+ refresher = (@refresh ? new_refresher : nil)
249
+ begin
250
+ begin
251
+ ret = yield @path
252
+ rescue StolenLockError
253
+ stolen = true
254
+ raise
255
+ end
256
+ ensure
257
+ begin
258
+ refresher.kill if refresher and refresher.status
259
+ ensure
260
+ unlock unless stolen
261
+ end
262
+ end
263
+ else
264
+ ObjectSpace.define_finalizer self, @clean if @clean
265
+ ret = self
266
+ end
267
+ rescue Errno::ESTALE, Errno::EIO => e
268
+ raise(NFSLockError, errmsg(e))
269
+ end
270
+ end
271
+
272
+ return ret
273
+ #--}}}
274
+ end
275
+ def sweep
276
+ #----{{{
277
+ begin
278
+ glob = File::join(@dirname, ".*lck")
279
+ paths = Dir[glob]
280
+ paths.each do |path|
281
+ begin
282
+ basename = File::basename path
283
+ pat = %r/^\s*\.([^_]+)_([^_]+)/o
284
+ if pat.match(basename)
285
+ host, pid = $1, $2
286
+ else
287
+ next
288
+ end
289
+ host.gsub!(%r/^\.+|\.+$/,'')
290
+ quad = host.split %r/\./
291
+ host = quad.first
292
+ pat = %r/^\s*#{ host }/i
293
+ if pat.match(HOSTNAME) and %r/^\s*\d+\s*$/.match(pid)
294
+ unless alive?(pid)
295
+ trace{ "process <#{ pid }> on <#{ host }> is no longer alive" }
296
+ trace{ "sweeping <#{ path }>" }
297
+ FileUtils::rm_f path
298
+ else
299
+ trace{ "process <#{ pid }> on <#{ host }> is still alive" }
300
+ trace{ "ignoring <#{ path }>" }
301
+ end
302
+ else
303
+ trace{ "ignoring <#{ path }> generated by <#{ host }>" }
304
+ end
305
+ rescue
306
+ next
307
+ end
308
+ end
309
+ rescue => e
310
+ warn(errmsg(e))
311
+ end
312
+ #----}}}
313
+ end
314
+ def alive? pid
315
+ #----{{{
316
+ pid = Integer("#{ pid }")
317
+ begin
318
+ Process::kill 0, pid
319
+ true
320
+ rescue Errno::ESRCH
321
+ false
322
+ end
323
+ #----}}}
324
+ end
325
+ def unlock
326
+ #--{{{
327
+ raise UnLockError, "<#{ @path }> is not locked!" unless @locked
328
+ begin
329
+ File::unlink @path
330
+ rescue Errno::ENOENT
331
+ raise StolenLockError, @path
332
+ ensure
333
+ @thief = false
334
+ @locked = false
335
+ ObjectSpace.undefine_finalizer self if @clean
336
+ end
337
+ #--}}}
338
+ end
339
+ def new_refresher
340
+ #--{{{
341
+ Thread::new(Thread::current, @path, @refresh) do |thread, path, refresh|
342
+ loop do
343
+ touch path
344
+ trace{"touched <#{ path }> @ <#{ Time.now.to_f }>"}
345
+ begin
346
+ loaded = load_lock_id(IO.read(path))
347
+ trace{"loaded <\n#{ loaded.inspect }\n>"}
348
+ raise unless loaded == @lock_id
349
+ rescue => e
350
+ trace{errmsg e}
351
+ thread.raise StolenLockError
352
+ Thread::exit
353
+ end
354
+ sleep refresh
355
+ end
356
+ end
357
+ #--}}}
358
+ end
359
+ def validlock?
360
+ #--{{{
361
+ if @max_age
362
+ uncache @path rescue nil
363
+ begin
364
+ return((Time.now - File::stat(@path).mtime) < @max_age)
365
+ rescue Errno::ENOENT
366
+ return nil
367
+ end
368
+ else
369
+ exist = File::exist?(@path)
370
+ return(exist ? true : nil)
371
+ end
372
+ #--}}}
373
+ end
374
+ def uncache file
375
+ #--{{{
376
+ refresh = nil
377
+ begin
378
+ is_a_file = File === file
379
+ path = (is_a_file ? file.path : file.to_s)
380
+ stat = (is_a_file ? file.stat : File::stat(file.to_s))
381
+ refresh = tmpnam(File::dirname(path))
382
+ File::link path, refresh
383
+ File::chmod stat.mode, path
384
+ File::utime stat.atime, stat.mtime, path
385
+ ensure
386
+ begin
387
+ File::unlink refresh if refresh
388
+ rescue Errno::ENOENT
389
+ end
390
+ end
391
+ #--}}}
392
+ end
393
+ def create_tmplock
394
+ #--{{{
395
+ tmplock = tmpnam @dirname
396
+ begin
397
+ create(tmplock) do |f|
398
+ @lock_id = gen_lock_id
399
+ dumped = dump_lock_id
400
+ trace{"lock_id <\n#{ @lock_id.inspect }\n>"}
401
+ f.write dumped
402
+ f.flush
403
+ yield f
404
+ end
405
+ ensure
406
+ begin; File::unlink tmplock; rescue Errno::ENOENT; end if tmplock
407
+ end
408
+ #--}}}
409
+ end
410
+ def gen_lock_id
411
+ #--{{{
412
+ Hash[
413
+ 'host' => "#{ HOSTNAME }",
414
+ 'pid' => "#{ Process.pid }",
415
+ 'ppid' => "#{ Process.ppid }",
416
+ 'time' => timestamp,
417
+ ]
418
+ #--}}}
419
+ end
420
+ def timestamp
421
+ #--{{{
422
+ time = Time.now
423
+ usec = time.usec.to_s
424
+ usec << '0' while usec.size < 6
425
+ "#{ time.strftime('%Y-%m-%d %H:%M:%S') }.#{ usec }"
426
+ #--}}}
427
+ end
428
+ def dump_lock_id lock_id = @lock_id
429
+ #--{{{
430
+ "host: %s\npid: %s\nppid: %s\ntime: %s\n" %
431
+ lock_id.values_at('host','pid','ppid','time')
432
+ #--}}}
433
+ end
434
+ def load_lock_id buf
435
+ #--{{{
436
+ lock_id = {}
437
+ kv = %r/([^:]+):(.*)/o
438
+ buf.each do |line|
439
+ m = kv.match line
440
+ k, v = m[1], m[2]
441
+ next unless m and k and v
442
+ lock_id[k.strip] = v.strip
443
+ end
444
+ lock_id
445
+ #--}}}
446
+ end
447
+ def tmpnam dir, seed = File::basename($0)
448
+ #--{{{
449
+ pid = Process.pid
450
+ time = Time.now
451
+ sec = time.to_i
452
+ usec = time.usec
453
+ "%s%s.%s_%d_%s_%d_%d_%d.lck" %
454
+ [dir, File::SEPARATOR, HOSTNAME, pid, seed, sec, usec, rand(sec)]
455
+ #--}}}
456
+ end
457
+ def create path
458
+ #--{{{
459
+ umask = nil
460
+ f = nil
461
+ begin
462
+ umask = File::umask 022
463
+ f = open path, File::WRONLY|File::CREAT|File::EXCL, 0644
464
+ ensure
465
+ File::umask umask if umask
466
+ end
467
+ return(block_given? ? begin; yield f; ensure; f.close; end : f)
468
+ #--}}}
469
+ end
470
+ def touch path
471
+ #--{{{
472
+ FileUtils.touch path
473
+ #--}}}
474
+ end
475
+ def getopt key
476
+ #--{{{
477
+ @opts[key] || @opts[key.to_s] || @opts[key.to_s.intern]
478
+ #--}}}
479
+ end
480
+ def to_str
481
+ #--{{{
482
+ @path
483
+ #--}}}
484
+ end
485
+ alias to_s to_str
486
+ def trace s = nil
487
+ #--{{{
488
+ STDERR.puts((s ? s : yield)) if @debug
489
+ #--}}}
490
+ end
491
+ def errmsg e
492
+ #--{{{
493
+ "%s (%s)\n%s\n" % [e.class, e.message, e.backtrace.join("\n")]
494
+ #--}}}
495
+ end
496
+ def attempt
497
+ #----{{{
498
+ ret = nil
499
+ loop{ break unless catch('attempt'){ ret = yield } == 'try_again' }
500
+ ret
501
+ #----}}}
502
+ end
503
+ def try_again!
504
+ #----{{{
505
+ throw 'attempt', 'try_again'
506
+ #----}}}
507
+ end
508
+ alias again! try_again!
509
+ def give_up!
510
+ #----{{{
511
+ throw 'attempt', 'give_up'
512
+ #----}}}
513
+ end
514
+ #--}}}
515
+ end
516
+
517
+ $__lockfile__ == __FILE__
518
+ end