命令行程序的 Memoize(缓存)?

命令行程序的 Memoize(缓存)?

有时我会一遍又一遍地运行相同的、相当昂贵的命令,以获得相同的输出。例如,ffprobe获取有关媒体文件的信息。给定相同的输入,应该总是产生相同的输出——因此缓存应该是可能的。

我见过记忆/缓存命令行输出但我正在寻找一种更彻底的实现:特别是,该实现似乎只是比较命令行 - 如果传递的文件之一被修改,它不会注意到。 (它还有一堆固定长度的缓冲区,这让我很怀疑,而且奇怪的是它是一个守护进程。)

在我开始写自己的文章之前,我很好奇是否已经存在。关键要求:

  • 如果任何输入文件(在命令行上)发生更改,则必须重新运行该命令
  • 如果任何命令行选项发生更改,则必须重新运行该命令
  • 我同意(并且诚实地期望)命令以“非交互”方式运行:例如,使用/dev/null标准输入,以及两个不同的文件作为标准输出和标准错误。
  • 如果命令出错,我可以选择将其与退出代码一起缓存,或者根本不缓存。
  • 鉴于上述情况,应尽可能频繁地返回缓存的内容。但正确性是第一位的。
  • 如果缓存可以在多台机器(全部在共同控制下)之间共享,例如通过 NFS,则更好。

基本上我想要做的,如果我自己写的话,是(为了简洁而跳过一些锁定和错误检查):获取命令行+命令行上每个项目的统计结果(错误或dev、inode、大小、mtime) )并通过 SHA-512 或 SHA-256 传递整个混乱。这将给出一个固定大小的密钥,但如果命令或文件发生更改,该密钥也会发生变化(除非有人进行了保留大小和运行时间的更改,在这种情况下,他们应得的)。检查该密钥是否在缓存目录中。如果它已经存在,请将其内容复制到 stdout 和 stderr。否则,在子进程中使用 stdin /dev/null 和两个文件(作为 stdout 和 stderr)运行该命令。如果成功,将文件放入缓存目录。然后将其内容复制到 stdout 和 stderr。如果结果是我自己写了,欢迎设计反馈。结果将是免费软件。

答案1

在很多情况下,您想要的东西不起作用,以至于您找不到能够提供真正良好结果的通用工具:

  • 访问不在命令行上的文件的命令。 ( locate myfile)
  • 访问网络的命令。 ( wget http://news.example.com/headlines)
  • 取决于时间的命令。 ( date)
  • 具有随机输出的命令。 ( pwgen)
  • ……

如果您负责决定在哪些命令上应用该工具,那么您需要的是构建工具:一种在命令的输出不是最新的情况下运行命令的工具。尊贵者制作不会很好:您必须手动定义依赖项,特别是您需要仔细分离不同命令的缓存,并在更改命令时手动撤销缓存,并且需要将每个缓存存储在单独的文件中,即不方便。中的一个许多选择也许更能胜任这项任务SCons它支持基于校验和和基于时间戳的依赖性分析,在此之上有一个缓存机制,并且可以通过编写 Python 代码进行调整。

答案2

这更像是一个大脑转储而不是真正的答案,但对于评论来说太长了。如果不合适我会删除它。请告诉我。耸肩

首先,我认为主要问题是您从“命令-->结果”的角度来思考。如果是“文件 --> 结果”,您可以使用make.如果只有少量固定数量的命令从文件引导到结果,您仍然可以使用:为每个命令make编写一个目标。make

如果您坚持应该是“任意命令 --> 结果”,首先想到的是某种 REPL,或者 shell-in-language-X。如今这些东西并不缺乏,似乎每两周左右就会出现一个新的。重点是,这些可以让你与结构化的数据,而不仅仅是一个字符串(命令)和多个文件。

dev获取+ inode+ size+的校验mtime和似乎是明智的。如果您担心误报,您始终可以进行完整比较(附带说明:完整比较始终比对每个文件采用 SHA-* 并比较结果更快)。对于后端,您可以使用 SQLite,但您需要某种机制来使旧记录过期。

如果您能够指出命令和/或文件的更多限制,事情可能会更容易。旨在实现“命令-->结果”的完全通用缓存,但仍然跟踪输入文件中的更改似乎有点过于雄心勃勃。

答案3

我在出于大致相同的目的编写自己的脚本时发现了这个问题,并且您关于使用 dev+inode+size+mtime 缓存文件的想法看起来非常有用,所以我添加了它。你的想法和我的实现不同,因为我很晚才偶然发现这个页面并决定不重写所有内容:

  1. 为了简单起见,该脚本将缓存条目存储在单个 YAML 文件中。您仍然可以在多台计算机上共享此文件,但存在 RCE 风险,而且由于 YAML 文件上的 TOCTOU,您还需要编写锁定包装器。

  2. 它可能只能在 Linux 上运行,如果幸运的话,也可以在其他 Unix 上运行。

  3. 使用风险自负。您的缓存内容将不受保护。

先跑gem install chronic_duration

#!/usr/bin/env ruby
# Usage: memoize [-D DATABASE] [-T TIMEOUT] [-F] [--] COMMAND [ARG]...
#     or memoize [-D DATABASE] --cleanup
#
# OPTIONS
#   -D DATABASE      Store entries in YAML format in DATABASE file.
#   -T TIMEOUT       Invalidate memoized entries older than TIMEOUT.
#   -F               Track file changes (dev+inode+size+mtime).
#   --cleanup        Remove all stale entries.

require 'date'
require 'optparse'
require 'digest'
require 'yaml'
require 'chronic_duration'
require 'open3'

MYSELF          = File.basename(__FILE__)
DEFAULT_DBFILE  = "#{Dir.home}/.config/memoize.yml"
DEFAULT_TIMEOUT = '1 week'

def fc(fpath) # File characteristic
  return [:dev, :ino, :size, :mtime].map do |s|
    Digest::SHA1.digest(Integer(File.stat(fpath).send(s)).to_s.b)
  end.join
end

def cmdline_checksum(cmdline, fchanges)
  pre_cksum_bytes = "".b

  cmdline.each do |c|
    characteristic   = (File.exists?(c) and fchanges) ? fc(c) : c
    pre_cksum_bytes += Digest::SHA1.digest(characteristic)
  end

  return Digest::SHA1.digest(pre_cksum_bytes)
end

def timed_out?(entry)
  return (entry[:timestamp] + Integer(entry[:timeout])) < Time.now
end

def pluralize(n, singular, plural)
  return (n % 100 == 11 || n % 10 != 1) ? plural : singular
end

fail "memoize: FATAL: this is a script, not a library" unless __FILE__ == $0

$dbfile   = DEFAULT_DBFILE
$timeout  = DEFAULT_TIMEOUT
$fchanges = false
$cleanup  = false
$retcode  = 0
$replay   = false

ARGV.options do |o|
  o.version = '2018.06.23'
  o.banner  = "Usage: memoize [OPTION]... [--] COMMAND [ARG]...\n"+
              "Cache results of COMMAND and replay its output"

  o.separator ""
  o.separator "OPTIONS"

  o.summary_indent = "  "
  o.summary_width  = 17

  o.on('-D=DATABASE', "Default: #{DEFAULT_DBFILE}")       { |d| $dbfile   = d    }
  o.on('-T=TIMEOUT',  "Default: #{DEFAULT_TIMEOUT}")      { |t| $timeout  = t    }
  o.on('-F', "Track file changes (dev+inode+size+mtime)") {     $fchanges = true }
  o.on('--cleanup', "Remove all stale entries")           {     $cleanup  = true }
end.parse!

begin
  File.open($dbfile, 'a') {}
  File.chmod(0600, $dbfile)
end unless File.exists?($dbfile)

db      = (YAML.load(File.read($dbfile)) or {})
cmdline = ARGV
cksum   = cmdline_checksum(cmdline, $fchanges)
entry   = {
  cmdline:   cmdline,
  timestamp: Time.now,
  timeout:   '1 week',
  stdout:    "",
  stderr:    "",
  retcode:   0,
}

if $cleanup
  entries = db.keys.select{|k| timed_out?(db[k]) }
  c = entries.count

  entries.each do |k|
    db.delete(k)
  end

  STDERR.puts "memoize: NOTE: #{c} stale #{pluralize(c, "entry", "entries")} removed"

  File.open($dbfile, 'w') { |f| f << YAML.dump(db) }

  exit
end

$replay = db.key?(cksum) && (not timed_out?(db[cksum]))

if $replay
  entry = db[cksum]
else
  Open3.popen3(*cmdline) do |i, o, e, t|
    i.close
    entry[:stdout]    = o.read
    entry[:stderr]    = e.read
    entry[:retcode]   = t.value.exitstatus
  end

  entry[:timestamp] = Time.now
  entry[:timeout]   = Integer(ChronicDuration.parse($timeout))
  db[cksum] = entry
end

$retcode = entry[:retcode]
STDOUT.write(entry[:stdout]) # NOTE: we don't record or replay stream timing
STDERR.write(entry[:stderr])
STDOUT.flush
STDERR.flush

File.open($dbfile, 'w') { |f| f << YAML.dump(db) }

exit! $retcode

相关内容