7
\$\begingroup\$

I am developing an application that works with files and folder on a network share (similar to the windows explorer). The application is used by multiple users and provides some commands for modifying the file system.

Problem

The parallel access to the file system may result in conflicts.

Example:

User A renames a folder (FolderA) while user B is executing a long-running process that moves / creates / deletes some of the sub folders of folder B. Therefore the rename breaks the operation of user B.

Requirements

  • An application should be able to lock folders if required.
  • Other applications should be notified as soon as possible about locks, so that these applications may disable corresponding commands (e.g. Rename will be disabled for a folder where any child folder is locked).
  • All kinds of network shares should be supported (windwos, samba, ...)
  • The application should be able to show the locking user / and timestamp.

Approach

To avoid conflicts as described above, a centralized locking mechanism should be introduced.

The idea is, to create a file on the network share. That file contains all current locks (one per line). Format: "RelativePath|User|DateTime".

Each application checks in certain intervals (e.g. 100 ms) if the file changed and updates it's internal state accordingly.

Each application may add / remove entries to / from the file.

Implementation

The API to the storage uses the following simple interface:

ILockStorage.cs

public interface ILockStorage : IDisposable
{
    event EventHandler<LockEntriesChangedEventArgs> LockEntriesChanged;

    void AddEntry(LockEntry entryToAdd);
    void RemoveEntry(LockEntry entryToRemove);
}

LockEntriesChangedEventArgs.cs

public class LockEntriesChangedEventArgs : EventArgs
{
    private readonly LockEntry[] myEntriesAdded;
    private readonly LockEntry[] myEntriesRemoved;

    public LockEntriesChangedEventArgs(LockEntry[] entriesAdded, LockEntry[] EntriesRemoved)
    {
        myEntriesAdded = entriesAdded;
        myEntriesRemoved = EntriesRemoved;
    }

    public LockEntry[] EntriesAdded { get { return myEntriesAdded; } }
    public LockEntry[] EntriesRemoved { get { return myEntriesRemoved; } }
}

LockEntry.cs

public class LockEntry : IEquatable<LockEntry>
{
    private readonly string myPath;
    private readonly string myUser;
    private readonly DateTime myLockTime;

    public LockEntry(string path, string user, DateTime lockTime)
    {
        path.Ensure("path").IsNotNull();
        user.Ensure("user").IsNotNull();

        myPath = path.ToLower().Trim('\\');
        myUser = user;
        myLockTime = lockTime;
    }

    public string User { get { return myUser; } }
    public string Path { get { return myPath; } }
    public DateTime LockTime { get { return myLockTime; } }

    public static bool TryParse(string input, out LockEntry lockEntry)
    {
        lockEntry = null;
        if (string.IsNullOrWhiteSpace(input))
            return false;

        var entryParts = input.Split('|');
        if (entryParts.Length != 3)
            return false;

        var path = entryParts[0];
        var user = entryParts[1];
        var lockTimeString = entryParts[2];
        DateTime lockTime;

        if (string.IsNullOrWhiteSpace(path) ||
            string.IsNullOrWhiteSpace(user) ||
            !DateTime.TryParse(lockTimeString, CultureInfo.InvariantCulture, DateTimeStyles.None, out lockTime))
            return false;

        lockEntry = new LockEntry(path, user, lockTime);
        return true;
    }

    public override string ToString()
    {
        return string.Format("{0}|{1}|{2}", Path, User, LockTime.ToString(CultureInfo.InvariantCulture));
    }

    public override bool Equals(object obj)
    {
        var other = obj as LockEntry;
        if (other == null)
            return false;

        return ((IEquatable<LockEntry>)this).Equals(other);
    }

    public override int GetHashCode()
    {
        return myLockTime.GetHashCode() ^
               myUser.GetHashCode() ^
               myPath.GetHashCode();
    }

    bool IEquatable<LockEntry>.Equals(LockEntry other)
    {
        return myLockTime == other.LockTime &&
               myUser == other.User &&
               myPath.Equals(other.Path);
    }
}

The implementation of the interface:

LockStorage.cs

internal class LockStorage : ILockStorage
{
    private const int WRITE_RETRY_COUNT = 20;
    private const int WRITE_RETRY_INTERVAL_MS = 50;

    private readonly Timer myTimer;
    private readonly FileInfo myFileInfo;

    private LockEntry[] myLockEntries = new LockEntry[0];
    private DateTime myLastSyncTimestamp = DateTime.MinValue;
    private bool myIsDisposed;

    public event EventHandler<LockEntriesChangedEventArgs> LockEntriesChanged;

    /* Nested Types
     * ********************************************************************/

    private abstract class LockEntryWriter
    {
        private readonly LockStorage myLockStorage;

        protected LockEntryWriter(LockStorage lockStorage)
        {
            myLockStorage = lockStorage;
        }

        public void Write()
        {
            myLockStorage.myTimer.Stop();
            try
            {
                IOException lastException = null;
                for (int i = 0; i < WRITE_RETRY_COUNT; i++)
                {
                    try
                    {
                        WriteInternal();
                        return;
                    }
                    catch (IOException ex)
                    {
                        // may occur if the file stream is open for writing / readings
                        lastException = ex;
                        Thread.Sleep(WRITE_RETRY_INTERVAL_MS);
                    }
                }

                // write failed
                throw new IOException("Unable to write to lock file. (retried " + WRITE_RETRY_COUNT + " times)", lastException);
            }
            finally
            {
                myLockStorage.myTimer.Start();
            }
        }

        private void WriteInternal()
        {
            LockEntry[] entries;
            using (var writeStream = OpenWriteStream(out entries))
            {
                if (!IsWriteRequired(entries))
                    return;

                using (var writer = new StreamWriter(writeStream))
                {
                    Write(writer, entries);
                    // truncate file
                    writeStream.SetLength(writeStream.Position);
                }
            }
        }

        private Stream OpenWriteStream(out LockEntry[] entries)
        {
            var fileInfo = myLockStorage.myFileInfo;
            var lastLyncTimestamp = myLockStorage.myLastSyncTimestamp;

            var readWriteStream = fileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);

            fileInfo.Refresh();
            entries = myLockStorage.LockEntries;
            if (lastLyncTimestamp < fileInfo.LastWriteTime)
            {
                var lines = ReadLockFile(readWriteStream);
                entries = ToLockEntries(lines.ToArray());
            }

            readWriteStream.Position = 0;
            return readWriteStream;
        }

        protected abstract bool IsWriteRequired(LockEntry[] currentEntries);
        protected abstract void Write(StreamWriter writer, LockEntry[] currentEntries);
    }

    private class AddLockEntryWriter : LockEntryWriter
    {
        private readonly LockEntry myEntryToAdd;

        public AddLockEntryWriter(LockStorage lockStorage, LockEntry entryToAdd)
            : base(lockStorage)
        {
            myEntryToAdd = entryToAdd;
        }

        protected override bool IsWriteRequired(LockEntry[] currentEntries)
        {
            return !currentEntries.Contains(myEntryToAdd);
        }

        protected override void Write(StreamWriter writer, LockEntry[] currentEntries)
        {
            foreach (var entry in currentEntries)
                writer.WriteLine(entry.ToString());
            writer.WriteLine(myEntryToAdd.ToString());
        }
    }

    private class RemoveLockEntryWriter : LockEntryWriter
    {
        private readonly LockEntry myEntryToRemove;

        public RemoveLockEntryWriter(LockStorage lockStorage, LockEntry entryToRemove)
            : base(lockStorage)
        {
            myEntryToRemove = entryToRemove;
        }

        protected override bool IsWriteRequired(LockEntry[] currentEntries)
        {
            return currentEntries.Contains(myEntryToRemove);
        }

        protected override void Write(StreamWriter writer, LockEntry[] currentEntries)
        {
            foreach (var entry in currentEntries.Except(new[] { myEntryToRemove }))
                writer.WriteLine(entry.ToString());
        }
    }

    /* .ctor
     * ********************************************************************/

    internal LockStorage(string lockfilePath, TimeSpan intervall)
    {
        myFileInfo = new FileInfo(lockfilePath);

        UpdateInternalState();

        myTimer = new Timer(intervall.Milliseconds);
        myTimer.AutoReset = false;
        myTimer.Elapsed += TimerElapsed;
        myTimer.Start();
    }

    /* public API
     * ********************************************************************/

    public LockEntry[] LockEntries { get { return myLockEntries.ToArray(); } }

    public void AddEntry(LockEntry entryToAdd)
    {
        new AddLockEntryWriter(this, entryToAdd).Write();
    }

    public void RemoveEntry(LockEntry entryToRemove)
    {
        new RemoveLockEntryWriter(this, entryToRemove).Write();
    }

    /* privates
     * ********************************************************************/

    private void TimerElapsed(object sender, ElapsedEventArgs e)
    {
        try
        {
            myFileInfo.Refresh();
            if (myFileInfo.LastWriteTime == myLastSyncTimestamp)
                return;

            UpdateInternalState();
        }
        catch (Exception ex)
        {
            // LogError("Error while reloading lock file", ex);
        }
        finally
        {
            myTimer.Start();
        }
    }

    private void UpdateInternalState()
    {
        try
        {
            string[] lines;
            using (var stream = myFileInfo.Open(FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read))
                lines = ReadLockFile(stream);
            if (lines == null)
                return;

            var lockEntries = ToLockEntries(lines);
            myLastSyncTimestamp = myFileInfo.LastWriteTime;
            UpdateLockEntries(lockEntries);
        }
        catch (IOException)
        {
            /* Ignore: May be occur if the file is being written at the moment */
        }
    }

    private void UpdateLockEntries(LockEntry[] lockEntries)
    {
        var entriesToRemove = new List<LockEntry>(myLockEntries);
        var addedEntries = new List<LockEntry>();
        foreach (var lockEntry in lockEntries)
        {
            entriesToRemove.Remove(lockEntry);
            if (myLockEntries.Contains(lockEntry))
                continue;
            addedEntries.Add(lockEntry);
        }

        if (addedEntries.Any() || entriesToRemove.Any())
        {
            myLockEntries = myLockEntries.Except(entriesToRemove)
                                         .Concat(addedEntries)
                                         .ToArray();

            OnLockEntriesChanged(addedEntries.ToArray(), entriesToRemove.ToArray());
        }
    }

    private static LockEntry[] ToLockEntries(string[] lines)
    {
        var lockEntries = new List<LockEntry>();
        foreach (var line in lines)
        {
            LockEntry lockEntry;
            if (!LockEntry.TryParse(line, out lockEntry))
            {
                //LogWarning("Unable to parse lock file entry'" + line + "'. Entry skipped.");
                continue;
            }
            lockEntries.Add(lockEntry);
        }
        return lockEntries.ToArray();
    }

    private static string[] ReadLockFile(Stream stream)
    {
        var lines = new List<string>();
        using (var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true))
            while (!reader.EndOfStream)
                lines.Add(reader.ReadLine());
        return lines.ToArray();
    }

    private void OnLockEntriesChanged(LockEntry[] entriesAdded, LockEntry[] entriesRemoved)
    {
        var handler = LockEntriesChanged;
        if (handler != null)
            handler(this, new LockEntriesChangedEventArgs(entriesAdded, entriesRemoved));
    }


    /* IDisposable implementation
     * ********************************************************************/

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    private void Dispose(bool disposing)
    {
        if (myIsDisposed)
            return;

        if (disposing)
        {
            myTimer.Stop();
            myTimer.Dispose();
        }

        myIsDisposed = true;
    }
}

I am primary interested in comments related to stability and robustness.

  • Are there any use cases that would break the storage?
  • Any alternative solutions for the problem of concurrent file access (read/write)?

However, all kinds of comments are welcome of course.

\$\endgroup\$
9
  • 2
    \$\begingroup\$ This sounds like a central database would be much better then a text file. \$\endgroup\$
    – t3chb0t
    Commented Jul 3, 2016 at 15:22
  • \$\begingroup\$ Thanks for your comment. I agree - that's true for applications that already have a database backend. Unfortunately, the application I am developing is working with file system only and I think that the use case doesn't justify to introduce a server-based database. If there is any lightweight file-based database that supports concurrent access and change notification I would give it a try - but didn't find one... \$\endgroup\$
    – JanDotNet
    Commented Jul 3, 2016 at 17:38
  • \$\begingroup\$ Then maybe a SQLite database? It's file based but you have the most andvantages of a regular database. I still think it would be a better choice. \$\endgroup\$
    – t3chb0t
    Commented Jul 3, 2016 at 17:44
  • 4
    \$\begingroup\$ Congratulations! You've re-invented the file system! Seriously though, can you explain a bit more about why you felt compelled to do this? What's the end-user problem that you're trying to solve? I suspect migrating your data store to a db (that already knows how to deal with concurrency) is the "end all be all" solution, but you've got me curious. \$\endgroup\$
    – RubberDuck
    Commented Jul 4, 2016 at 12:58
  • 1
    \$\begingroup\$ @JanDotNet thank you. Much more clear now. Have you considered using a client-server approach? The server could be responsible for maintaining a queue of operations/locks. Could also use a SignalR hub to send notifications to your client apps instead of the polling method you're currently using. \$\endgroup\$
    – RubberDuck
    Commented Jul 4, 2016 at 15:01

4 Answers 4

7
\$\begingroup\$
  1. There is no need to cast ((IEquatable<LockEntry>)this).Equals(other);, you can just call Equals(other)
  2. It's weird to use both Equals and == for the same type in the same bool expression:

    myUser == other.User &&
    myPath.Equals(other.Path);
    

    Just stick to one or the other, so reader does not have to look for deeper meaning to all this. :)

  3. SHOUT_CASE is unusual in C#. You should use PascalCase for constants instead: WriteRetryCount.
  4. I think you should extract constants to separate class, so you can easily serialize it later on and tweak those values without re-compilation. You can call it LockStorageSettings or something. You can move filePath and timer interval there as well.
  5. You shouldn't use nested classes to build class hierarchy. I'd say, that if nested class is larger than 50 lines - it probably deserves to be moved to dedicated .cs file.
  6. Default encoding may vary depending on your users' environments. Since you are using a shared file, you have to specify encoding explicitly when writing and reading, to make sure that all the users are using the same encoding.
  7. OpenWriteStream does too much: it opens the stream AND it parses the entries. Split it into two methods and replace out with proper return value. Nobody likes using out. :)
  8. I think Write method is the wrong method to override. I mean writing logic does not change, its fixed: you take a bunch of entries, and you write them one by one. What changes is which entries you have to write. So instead of abstract Write you should have abstract IEnumerable<LockEntry> GetUpdatedEntries(IEnumerable<LockEntry> oldEntries).
  9. Your LockStorage class is tightly coupled with LockEntryWriter class which is a sign of bad design. LockEntryWriter should have a single responsibility: writing entries to file. It should not manage timer or update cache of parent component. IMHO, you should remove LockStorage reference from LockEntryWriter constructor.
\$\endgroup\$
3
  • \$\begingroup\$ 1, 2, 3. Agree. 4. that is only for the review. Actually I put such settings in the .Net settings file (so it can be changed in the app.config). 5. OK - I like nested classes because it makes clear that the code represents internal logic. On the other hand, it blows up the class / file so your point is absolut valid. 6. Agree - will be changed.... \$\endgroup\$
    – JanDotNet
    Commented Jul 4, 2016 at 14:42
  • \$\begingroup\$ 8. Agree, has been refactored. 8. Sure, thanks!... sometimes I am blind. 9. Agree, I am also not happy with that. Timer can be stopped/started outside... I think it makes sense to read the last entries always (not just if they changed). That is a little bit slower but code will be less complex. Then the LockEntryWriter does only depend on the FileInfo object. :) \$\endgroup\$
    – JanDotNet
    Commented Jul 4, 2016 at 15:09
  • \$\begingroup\$ After refactoring the LockEntryWriter it turns out that they can be used independent of the `LocKStorage', so I moved them to a singe file (as you suggested). The whole code looks much better now. Thanks again. \$\endgroup\$
    – JanDotNet
    Commented Jul 4, 2016 at 15:51
3
\$\begingroup\$

my my my

This is obviously subjective, however I'm not a huge fan of prefixing member variables with my. It makes the code read like it's been knocked up to try an idea (I'm not suggesting this is what you have done, simply that the variable naming gives that impression). This may be because I'd tend to use fields in things like Equals, so would end up with:

return myLockTime == other.myLockTime &&
       myUser == other.myUser &&
       myPath.Equals(other.myPath);

Which to me looks odd.

Fields Vs Properties

In your equals you reference the member fields for the class the operation is being performed on, but the properties of the class it's being compared to.

bool IEquatable<LockEntry>.Equals(LockEntry other)
{
    return myLockTime == other.LockTime &&
           myUser == other.User &&
           myPath.Equals(other.Path);
}

I would tend to use either properties on both sides, or fields on both sides. Primarily because if the property is changed to do anything other than return the field you could end up with different results when comparing a==b and b==a.

Construction Order

Again, this is probably personal taste, but I'd finish constructing the class before updating the internal state:

internal LockStorage(string lockfilePath, TimeSpan intervall)
{
    myFileInfo = new FileInfo(lockfilePath);
    myTimer = new Timer(intervall.Milliseconds);
    myTimer.AutoReset = false;
    myTimer.Elapsed += TimerElapsed;

    UpdateInternalState();

    myTimer.Start();
}

Locking?

The locking you're implementing is cooperative locking. All parties within the system need to comply with the locking process in order for it to work. With networked file systems this is often not going to be the case, you may be competing against various other applications accessing the files (explorer, cron jobs, shells etc), although you could manage this at an operational level. With that in mind, whilst your locking code can be used to drive your menus to help prevent the user getting into trouble, I'd suggest that the code that actually performs operations needs to be aware that it might not really have exclusive access and be ready to handle errors appropriately.

Paths

It feels like you're assuming all clients access a given folder/file using the same path. Again, you may be handling this at an operational level however it's worth asking if your system will work if two users map the same network folder to different drives, or if there are nested network shares and two different users access the folder from different share levels. i.e.

These shares exist:

c:\SomeFolder               
c:\SomeFolder\ProjectA 

And one users Accesses a file as:

\\machine\SomeFolder\ProjectA\File1

And another uses

\\machine\ProjectA\File1

Singleton?

Does it make sense for an application to have more than one instance of the Storage class? If not, then should it be enforcing this through some kind of singleton model?

Cleanup and Self Contention

As far as I can tell, the code you've posted doesn't actually track which locks have been created by a particular instance of the Storage (or the application it is running in). If a bug/crash/network failure results in RemoveEntry not being called then the 'lock file' will end up with an entry in it that isn't necessary.

If the user starts up the application again, does it take over responsibility for those locks? Is there a cleanup app that allows locks to be removed? Are warnings raised when locks have been held for too long?

It feels a bit like your Storage class should be tracking entries that have been added by it and then ensuring that they are removed as part of the Dispose, however that might not actually make sense in the context of your overall application.

I'd also be a bit concerned that if you're locking by user it would be possible for a user to run two instances of the application (I do this all the time with explorer) and perform two interacting changes without realising it as both instances believe they hold the lock. This is often overcome by storing an additional piece of information, such as the processId that holds the lock.

\$\endgroup\$
5
  • 1
    \$\begingroup\$ ...for 3rd party libs it's thierUser and theirTimer ;-) Look boss, this is my code. \$\endgroup\$
    – t3chb0t
    Commented Jul 5, 2016 at 9:06
  • \$\begingroup\$ Thanks for the review! my my my We are using prefix 'my' for instance variables and 'the' for static ones, so there is a certain logic in the naming. But you are right, that logic fails if fields of other instances are accessed. Fields Vs Properties: Agree, it is better to use fields. Construction Order Agree, it is better to initialize the instance before calling mehtods. \$\endgroup\$
    – JanDotNet
    Commented Jul 5, 2016 at 16:26
  • \$\begingroup\$ Paths/Singleton Actually the paths in LockEntryare normalized (path.ToLower().Trim('\\')) relative paths. The root path is the path where the lock file is located. One application may have multiple root pathes and therfore multiple LockStorage instances. That naming mechanism should be solid, isn't it? \$\endgroup\$
    – JanDotNet
    Commented Jul 5, 2016 at 16:38
  • \$\begingroup\$ Cleanup and Self Contention If the application crashs and the lock remains, the folder has to be unlocked manually. Some of the users have the ability to unlock folders, therfore that seems to be a simple and pragmatic way without tracking the lock originator. \$\endgroup\$
    – JanDotNet
    Commented Jul 5, 2016 at 16:45
  • \$\begingroup\$ @JanDotNet Your responses seem reasonable. I've posted another answer because it feels like something is missing, however that may simply be that you've got it in other code that you haven't posted, or it could be I've misunderstood something. Feel free to downvote if it's not useful. \$\endgroup\$
    – forsvarir
    Commented Jul 5, 2016 at 21:03
1
\$\begingroup\$

I'm adding this as a separate answer, because I feel a bit like I'm missing something, which you may well be managing in another class that you haven't posted (and this is too big for a comment). Looking at the problem you've said you want to avoid this situation:

User A renames a folder (FolderA) while user B is executing a long-running process that moves / creates / deletes some of the sub folders of folder B. Therefore the rename breaks the operation of user B.

So, lets assume the following folder structure:

Root
    \FolderA
        \Sub1
            \FolderB
               \Files
        \Sub2
            \Files

User B initiates the long running process coping files from FolderB to Sub2. To do that, they AddLockEntry for FolderA? (or FolderB and Sub2).

User A comes along and tries to rename FolderA to FolderC. To do that, they either lock Root or more specifically they try to lock FolderA. For the lock to be valid, none of the subfolders can have a lock on them? The code that checks this doesn't appear to be in the code you've posted.

Assuming I'm correct about the expected behaviour, and that the code does exist somewhere else, it would raise a concern about the way AddEntry works. AddEntry essentially does this processing:

Open the file for writing
Read all the entries in the file
Check that the specific entry to be added doesn't exist
Add the new entry
Write all of the entries back to the file

There are a couple of concerns with this process:

As there are no checks at this lower level for the nested directory lock then if for any reason the higher level decision making gets out of sync it may attempt to gain a lock that it shouldn't.

LockEntries are compared based on 'path,user,time':

bool IEquatable<LockEntry>.Equals(LockEntry other)
{
    return myLockTime == other.LockTime &&
           myUser == other.User &&
           myPath.Equals(other.Path);
}

Which is used to check if the entry already exists:

protected override bool IsWriteRequired(LockEntry[] currentEntries)
{
    return !currentEntries.Contains(myEntryToAdd);
}

Surely the path is the important bit of the check. It should be saying don't create an entry if there is already one for that path, not 'for that path with a given user at a specific time`. The time aspect in particular seems like it will result in the match never being made.

Maybe you're handling all of this in the higher level code and filtering log entries based on the time they were created to decide if the application holds a lock or not, however it then relies on all your clocks being in sync which can be an issue.

\$\endgroup\$
5
  • \$\begingroup\$ Wow, I didn't think that anybody digging so deep into the business. \$\endgroup\$
    – JanDotNet
    Commented Jul 6, 2016 at 6:04
  • \$\begingroup\$ 1) The case u described is actually handled on a higher level. The copy operation from user B has to lock FolderB and Sub2. The LockStorage only holds that 2 locks. However, all folders above FolderB/Sub2 are also locked with a special lock status 'LockOrigin=Child' and all folder below FolderB/Sub2 are locked with a special lock staus 'LockOrigin=Parent'. Therfore, the rename operation will be disabled for FolderA because FolderA is indirect locked. However, create folder is not disabled for FolderA because it is compatible to locks where 'LockOrigin=Child'. \$\endgroup\$
    – JanDotNet
    Commented Jul 6, 2016 at 6:04
  • \$\begingroup\$ 2) You are absolut right, it makes no sense to check the name and date time in Equals. Actually I already fixed it in the productive code but didn't update the code view post yet. \$\endgroup\$
    – JanDotNet
    Commented Jul 6, 2016 at 6:05
  • \$\begingroup\$ @JanDotNet Race conditions are one of those things that can keep me up at night... The reality is that even if you were to have a race condition, it is unlikely to trigger as from the sound of it locks are initiated by user decisions and people don't make decisions that often or quickly so the chance of the exact conditions being met is extremely small. The one thing you might want to consider is adding an AddEntries method so that it's possible to lock multiple folders (for example FolderB,Sub2) in an atomic operation that either suceeds or fails, but doesn't half succeed. \$\endgroup\$
    – forsvarir
    Commented Jul 6, 2016 at 8:11
  • 1
    \$\begingroup\$ "The one thing you might want to consider is adding an AddEntries method so that it's possible to lock multiple folders [...] in an atomic operation": Yes, that is actually a meaningful improvement... However I'll not implemented it, because in my case it is very unlikely that locking fails because of the non-atomic lock mechanism (there are ~10 users who are working mostly on different directories). And even if it happens, it doesn't produce half-processed data - the operation will just fail. Further more I already finished the implementation ;). \$\endgroup\$
    – JanDotNet
    Commented Jul 7, 2016 at 6:19
1
\$\begingroup\$

After applying the suggestion from Nikita B and forsvarir, the class LockStorage could be refactored to the following:

LockStorage.cs

internal class LockStorage : ILockStorage
{
    private readonly Timer myTimer;
    private readonly FileInfo myFileInfo;

    private LockEntry[] myLockEntries = new LockEntry[0];
    private DateTime myLastSyncTimestamp = DateTime.MinValue;
    private bool myIsDisposed;

    public event EventHandler<LockEntriesChangedEventArgs> LockEntriesChanged;


    /* .ctor
     * ********************************************************************/

    internal LockStorage(string lockfilePath, TimeSpan intervall)
    {
        myFileInfo = new FileInfo(lockfilePath);    
        myTimer = new Timer(intervall.Milliseconds);
        myTimer.AutoReset = false;
        myTimer.Elapsed += TimerElapsed;

        UpdateInternalState();

        myTimer.Start();
    }

    /* public API
     * ********************************************************************/

    public LockEntry[] LockEntries { get { return myLockEntries.ToArray(); } }

    public void AddEntry(LockEntry entryToAdd)
    {
        var writer = new AddLockEntryWriter(myFileInfo, entryToAdd);
        ExecuteLockEntryWriter(writer);
    }

    public void RemoveEntry(LockEntry entryToRemove)
    {
        var writer = new RemoveLockEntryWriter(myFileInfo, entryToRemove);
        ExecuteLockEntryWriter(writer);
    }

    private void ExecuteLockEntryWriter(LockEntryWriter writer)
    {
        try
        {
            myTimer.Start();
            writer.Write();
        }
        finally
        {
            myTimer.Start();
        }
    }

    /* privates
     * ********************************************************************/

    private void TimerElapsed(object sender, ElapsedEventArgs e)
    {
        try
        {
            myFileInfo.Refresh();
            if (myFileInfo.LastWriteTime == myLastSyncTimestamp)
                return;

            UpdateInternalState();
        }
        catch (Exception ex)
        {
            Logger.Error("Error while reloading lock file", ex);
        }
        finally
        {
            myTimer.Start();
        }
    }

    private void UpdateInternalState()
    {
        try
        {
            string[] lines;
            // NOTE: Changing parameters requires to update unit test TestWritingWhileRead
            using (var stream = myFileInfo.Open(FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read))
                lines = Helper.ReadLockFile(stream);
            if (lines == null)
                return;

            var lockEntries = Helper.ToLockEntries(lines);
            myLastSyncTimestamp = myFileInfo.LastWriteTime;
            UpdateLockEntries(lockEntries);
        }
        catch (IOException)
        {
            /* Ignore: May be occur if the file is being written at the moment */
        }
    }

    private void UpdateLockEntries(LockEntry[] lockEntries)
    {
        var entriesToRemove = new List<LockEntry>(myLockEntries);
        var addedEntries = new List<LockEntry>();
        foreach (var lockEntry in lockEntries)
        {
            entriesToRemove.Remove(lockEntry);
            if (myLockEntries.Contains(lockEntry))
                continue;
            addedEntries.Add(lockEntry);
        }

        if (addedEntries.Any() || entriesToRemove.Any())
        {
            myLockEntries = myLockEntries.Except(entriesToRemove)
                                         .Concat(addedEntries)
                                         .ToArray();

            OnLockEntriesChanged(addedEntries.ToArray(), entriesToRemove.ToArray());
        }
    }

    private void OnLockEntriesChanged(LockEntry[] entriesAdded, LockEntry[] entriesRemoved)
    {
        var handler = LockEntriesChanged;
        if (handler != null)
            handler(this, new LockEntriesChangedEventArgs(entriesAdded, entriesRemoved));
    }


    /* IDisposable implementation
     * ********************************************************************/

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    private void Dispose(bool disposing)
    {
        if (myIsDisposed)
            return;

        if (disposing)
        {
            myTimer.Stop();
            myTimer.Dispose();
        }

        myIsDisposed = true;
    }
}

LockEntryWriter.cs

internal abstract class LockEntryWriter
{
    private const int WRITE_RETRY_COUNT = 20;
    private const int WRITE_RETRY_INTERVAL_MS = 50;

    private readonly FileInfo myFileInfo;

    protected LockEntryWriter(FileInfo fileInfo)
    {
        myFileInfo = fileInfo;
    }

    public void Write()
    {
        IOException lastException = null;
        for (int i = 0; i < WRITE_RETRY_COUNT; i++)
        {
            try
            {
                WriteInternal();
            }
            catch (IOException ex)
            {
                // may occur if the file stream is open for writing / readings
                lastException = ex;
                Thread.Sleep(WRITE_RETRY_INTERVAL_MS);
            }
        }

        // write failed
        throw new IOException("Unable to write to lock file. (retried " + WRITE_RETRY_COUNT + " times)", lastException);
    }

    private void WriteInternal()
    {
        using (var readWriteStream = OpenWriteStream())
        {
            var entries = ReadEntries(readWriteStream);

            VerifyOperationIsValid(entries);

            readWriteStream.Position = 0;

            using (var writer = new StreamWriter(readWriteStream, Encoding.UTF8))
            {
                foreach (var entry in GetEntriesToWrite(entries))
                    writer.WriteLine(entry);

                // truncate file
                readWriteStream.SetLength(readWriteStream.Position);
            }
        }
    }

    private LockEntry[] ReadEntries(Stream readWriteStream)
    {
        var lines = Helper.ReadLockFile(readWriteStream);
        return Helper.ToLockEntries(lines.ToArray());
    }

    private Stream OpenWriteStream()
    {
        // NOTE: Changing open mode require to adjust the unit test: TestReadWhileWriting
        return myFileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
    }

    protected abstract void VerifyOperationIsValid(LockEntry[] currentEntries);
    protected abstract IEnumerable<LockEntry> GetEntriesToWrite(LockEntry[] currentEntries);
}

AddLockEntryWriter.cs

internal class AddLockEntryWriter : LockEntryWriter
{
    private readonly LockEntry myEntryToAdd;

    public AddLockEntryWriter(FileInfo info, LockEntry entryToAdd)
        : base(info)
    {
        myEntryToAdd = entryToAdd;
    }

    protected override void VerifyOperationIsValid(LockEntry[] currentEntries)
    {
        if (currentEntries.Contains(myEntryToAdd))
            throw new InvalidOperationException("Unable to lock folder ''" + myEntryToAdd.Path + "' because it is already locked.");
    }

    protected override IEnumerable<LockEntry> GetEntriesToWrite(LockEntry[] currentEntries)
    {
        foreach (var entry in currentEntries)
            yield return entry;
        yield return myEntryToAdd;
    }
}

RemoveLockEntryWriter.cs

internal class RemoveLockEntryWriter : LockEntryWriter
{
    private readonly LockEntry myEntryToRemove;

    public RemoveLockEntryWriter(FileInfo info, LockEntry entryToRemove)
        : base(info)
    {
        myEntryToRemove = entryToRemove;
    }

    protected override void VerifyOperationIsValid(LockEntry[] currentEntries)
    {
        if (!currentEntries.Contains(myEntryToRemove))
            throw new InvalidOperationException("Unable to unlock folder ''" + myEntryToRemove.Path + "' because it is not locked.");
    }

    protected override IEnumerable<LockEntry> GetEntriesToWrite(LockEntry[] currentEntries)
    {
        return currentEntries.Except(new[] { myEntryToRemove });
    }
}

Helper.cs

internal static class Helper
{
    internal static string[] ReadLockFile(Stream stream)
    {
        var lines = new List<string>();
        using (var reader = new StreamReader(stream, Encoding.UTF8, true, 1024, true))
            while (!reader.EndOfStream)
                lines.Add(reader.ReadLine());
        return lines.ToArray();
    }

    internal static LockEntry[] ToLockEntries(string[] lines)
    {
        var lockEntries = new List<LockEntry>();
        foreach (var line in lines)
        {
            LockEntry lockEntry;
            if (!LockEntry.TryParse(line, out lockEntry))
            {
                Logger.Warn("Unable to parse lock file entry'" + line + "'. Entry skipped.");
                continue;
            }
            lockEntries.Add(lockEntry);
        }
        return lockEntries.ToArray();
    }
}

LockEntry.cs

public class LockEntry : IEquatable<LockEntry>
{
    private readonly string myPath;
    private readonly string myUser;
    private readonly DateTime myLockTime;

    public LockEntry(string path, string user, DateTime lockTime)
    {
        path.Ensure("path").IsNotNull();
        user.Ensure("user").IsNotNull();

        myPath = path.ToLower().Trim('\\', ' ');
        myUser = user;
        myLockTime = lockTime;
    }

    public string User { get { return myUser; } }
    public string Path { get { return myPath; } }
    public DateTime LockTime { get { return myLockTime; } }

    public static bool TryParse(string input, out LockEntry lockEntry)
    {
        lockEntry = null;
        if (string.IsNullOrWhiteSpace(input))
            return false;

        var entryParts = input.Split('|');
        if (entryParts.Length != 3)
            return false;

        var path = entryParts[0];
        var user = entryParts[1];
        var lockTimeString = entryParts[2];
        DateTime lockTime;

        if (string.IsNullOrWhiteSpace(path) ||
            string.IsNullOrWhiteSpace(user) ||
            !DateTime.TryParse(lockTimeString, CultureInfo.InvariantCulture, DateTimeStyles.None, out lockTime))
            return false;

        lockEntry = new LockEntry(path, user, lockTime);
        return true;
    }

    public override string ToString()
    {
        return string.Format("{0}|{1}|{2}", Path, User, LockTime.ToString(CultureInfo.InvariantCulture));
    }

    public override bool Equals(object obj)
    {
        var other = obj as LockEntry;
        if (other == null)
            return false;

        return ((IEquatable<LockEntry>)this).Equals(other);
    }

    public override int GetHashCode()
    {
        return myPath.GetHashCode();
    }

    bool IEquatable<LockEntry>.Equals(LockEntry other)
    {
        return myPath == other.myPath;
    }
\$\endgroup\$
5
  • \$\begingroup\$ You should ask a new question with the refactored code. \$\endgroup\$
    – t3chb0t
    Commented Jul 4, 2016 at 16:41
  • \$\begingroup\$ @t3chb0t: I just want to show the refactored version of my code. Regarding to point 3 of this meta post it is valid to self-answer questions that way: "If you want to show everyone how you improved your code, but don't want to ask another question, then post an answer to your own question" \$\endgroup\$
    – JanDotNet
    Commented Jul 4, 2016 at 18:45
  • 1
    \$\begingroup\$ You new interface for AddEntry, RemoveEntry and Write isn't very intuitive. If I'm calling a method called 'Add...` etc, that returns a bool, I'm expecting that it will be returning true on success and false on failure. From what I can tell, yours returns true if the entry is added, or false if you decide that it didn't need to be added (presumably because it's already there), and it throws an exception if the record isn't added (because of a failure to open the lock file). This means that no matter if the method returns (true or false), the caller still holds the lock? \$\endgroup\$
    – forsvarir
    Commented Jul 4, 2016 at 19:05
  • \$\begingroup\$ @forsvarir: Thats right.. maybe I should drop the return value for the methods AddEntry / RemoveEntry. Do you think it is better to throw an exception if the lock exists (for AddEntry) or just ignore it and do nothing!? \$\endgroup\$
    – JanDotNet
    Commented Jul 4, 2016 at 19:18
  • \$\begingroup\$ @forsvarir: I updated the code in the answer. Probably it is better to throw an exception, because if the folder is already locked anything went wrong. \$\endgroup\$
    – JanDotNet
    Commented Jul 4, 2016 at 19:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.