sourceafConcurrent::SynchronizedFileMap.fan

using concurrent

** A 'Synchronized' cache whose values update should their associated file key be modified.
** Values are updated upon calling 'getOrAddOrUpdate()' - they do not update themselves autonomously!   
** 
** To prevent excessive polling of the file system, and given every call to 'File.exists()' typically 
** takes [at least 8ms-12ms]`http://stackoverflow.com/questions/6321180/how-expensive-is-file-exists-in-java#answer-6321277`, 
** you can set a timeout 'Duration'. Every file in the cache notes when it polled the file system 
** last, and waits at least this amount of time before polling again.
** 
** Note that values are only added to the cache should the file actually exist. Given there are an 
** infinite number of files that don't exist, this prevents your cache from growing unbounded.
** (Also, how do you tell if a file has been modified if it doesn't exist!?)
** 
** Upon calling 'set()', 'getOrAdd()' or 'getOrAddOrUpdate()' should a file not exist (or has 
** subsequently been deleted) then the value function is still executed and its result returned. 
** However nothing is stored in the cache, and any previous value keyed against the file is removed.
** This ensures that the content of the cache only relates to files that exist.
** 
** Although surprising, the above behaviour is usually what you want and works very well.  
** 
** Note that all objects held in the cache have to be immutable.
// Used by efanXtra, Genesis, BsMoustache, BsEfan
const class SynchronizedFileMap {
    private const SynchronizedMap   cache
    private const AtomicMap         fileData

    ** The duration between individual file checks.
    ** Use to avoid excessive reads of the file system.
    ** Set to 'null' to check the file *every* time.
    const Duration?     timeout

    ** The 'lock' object should you need to 'synchronize' on the file map.
    const Synchronized  lock

    ** The default value to use for `get` when a key isn't mapped.
    const Obj? def              := null

    ** Used to parameterize the backing map. 
    const Type valType          := Obj?#

    ** Creates a 'SynchronizedMap' with the given 'ActorPool' and 'timeout'.
    new make(ActorPool actorPool, Duration? timeout := 30sec, |This|? f := null) {
        this.timeout    = timeout
        f?.call(this)
        this.cache      = SynchronizedMap(actorPool) { it.keyType = File#; it.valType = this.valType  }
        this.fileData   = AtomicMap()                { it.keyType = File#; it.valType = FileModState# }
        this.lock       = cache.lock
    }
    
    @NoDoc @Deprecated { msg="Use 'val' instead" }
    [File:Obj?] map {
        get { val }
        set { val = it }
    }

    ** Gets or sets a read-only copy of the backing map.
    [File:Obj?] val {
        get { cache.val }
        
        // private until I sync the file keys with the 'fileData' map 
        private set { cache.val = it }
    }

    ** Sets the key / value pair, ensuring no data is lost during multi-threaded race conditions.
    ** 
    ** Nothing is added should the file not exist.
    @Operator
    Void set(File key, Obj? item) {
        ConcurrentUtils.checkType(item?.typeof, valType, "Map value")
        iKey := key.toImmutable
        iVal := item?.toImmutable
        cache.lock.synchronized |->| {
            setFile(iKey, |->Obj?| { iVal })
        }
    }

    ** Remove all key/value pairs from the map. Return this.
    This clear() {
        cache.lock.synchronized |->| {
            cache.val = cache.val.rw.clear
            fileData.clear
        }       
        return this
    }
    
    ** Remove the key/value pair identified by the specified key
    ** from the map and return the value. 
    ** If the key was not mapped then return 'null'.
    Obj? remove(File key) {
        iKey := key.toImmutable
        return cache.lock.synchronized |->Obj?| {
            newMap := cache.val.rw
            val := newMap.remove(iKey)
            fileData.remove(iKey)
            cache.val = newMap
            return val 
        }
    }

    ** Returns the value associated with the given key. 
    ** If the key is not mapped then it is added from the value function.
    ** 
    ** If the file does not exist, the 'valFunc' is executed but nothing is added to the cache. 
    **  
    ** Note that 'valFunc' should be immutable and, if used, is executed in a different thread to the calling thread.
    Obj? getOrAdd(File key, |File->Obj?| valFunc) {
        if (containsKey(key))
            return get(key)
        
        iKey  := key.toImmutable
        iFunc := valFunc.toImmutable
        return cache.lock.synchronized |->Obj?| {
            // double lock
            if (containsKey(iKey))
                return get(iKey)

            return setFile(iKey, iFunc)
        }
    }
    
    ** Returns the value associated with the given file. 
    ** If it doesn't exist, **or the file has been modified** since the last call to 'getOrAddOrUpdate()', 
    ** then it is added from the given value function. 
    ** 
    ** Set 'timeout' in the ctor to avoid hitting the file system on every call to this method.
    ** 
    ** If the file does not exist, the 'valFunc' is executed but nothing is added to the cache.
    **  
    ** Note that 'valFunc' should be immutable and, if used, is executed in a different thread to the calling thread.
    Obj? getOrAddOrUpdate(File key, |File->Obj?| valFunc) {
        fileMod := (FileModState?) fileData[key]
        
        if (fileMod?.isTimedOut(timeout) ?: true) {
            iKey  := key.toImmutable
            iFunc := valFunc.toImmutable
            return cache.lock.synchronized |->Obj?| {
                // double lock
                fileMod2 := (FileModState?) fileData[iKey]              
                if (fileMod2?.isTimedOut(timeout) ?: true) {

                    if (fileMod2?.isModified(iKey) ?: true)
                        return setFile(iKey, iFunc)

                    if (!iKey.exists)
                        return setFile(iKey, iFunc)             

                    // just update the last checked
                    fileData.set(iKey, FileModState(iKey.modified))
                }
                return cache[key]
            }
        }

        return cache[key]
    }

    ** Returns 'true' if a subsequent call to 'getOrAddOrUpdate()' would result in the 'valFunc' 
    ** being executed. 
    ** This method does not modify any state.
    Bool isModified(File key) {
        fileMod := (FileModState?) fileData[key]
        if (fileMod == null)
            return true
        if (!fileMod.isTimedOut(timeout))
            return false
        return fileMod.isModified(key)
    }
    
    private Obj? setFile(File iKey, |File->Obj?| iFunc) {
        val  := iFunc.call(iKey)
        ConcurrentUtils.checkType(val?.typeof, valType, "Map value")
        iVal := val?.toImmutable
        
        // only cache when the file exists
        if (iKey.exists) {
            newMap := cache.val.rw
            newMap.set(iKey, iVal)
            fileData.set(iKey, FileModState(iKey.modified))
            cache.val = newMap
        
        } else if (cache.containsKey(iKey)) {
            newMap := cache.val.rw
            newMap.remove(iKey)
            fileData.remove(iKey)
            cache.val = newMap
        }

        return iVal
    }
    
    // ---- Common Map Methods --------------------------------------------------------------------

    ** Returns 'true' if the map contains the given file
    Bool containsKey(File key) {
        val.containsKey(key)
    }
    
    ** Call the specified function for every key/value in the map.
    Void each(|Obj? item, File key| c) {
        val.each(c)
    }

    ** Returns the value associated with the given key. 
    ** If key is not mapped, then return the value of the 'def' parameter.  
    ** If 'def' is omitted it defaults to 'null'.
    @Operator
    Obj? get(File key, Obj? def := this.def) {
        val.get(key, def)
    }

    ** Return 'true' if size() == 0
    Bool isEmpty() {
        val.isEmpty
    }

    ** Returns a list of all the mapped keys.
    Obj[] keys() {
        val.keys
    }

    ** Get a read-write, mutable Map instance with the same contents.
    [Obj:Obj?] rw() {
        val.rw
    }

    ** Get the number of key/value pairs in the map.
    Int size() {
        val.size
    }
    
    ** Returns a list of all the mapped values.
    Obj?[] vals() {
        val.vals
    }

    @NoDoc
    override Str toStr() {
        "SynchronizedFileMap - size=${size}"
    }
}

internal const class FileModState {
    const DateTime  lastChecked
    const DateTime  lastModified    // pod files have last modified info too!
    
    new make(DateTime lastModified) {
        this.lastChecked    = DateTime.now
        this.lastModified   = lastModified
    }   
    
    Bool isTimedOut(Duration? timeout) {
        timeout == null
            ? true
            : (DateTime.now - lastChecked) > timeout
    }
    
    ** Returns 'false' when the file doesn't exist
    Bool isModified(File file) {
        file.modified > lastModified
    }
}