Post

Arkanjo — Improving Cache Compatibility Across Versions

Arkanjo — Improving Cache Compatibility Across Versions

Arkanjo Contribution

Introduction

As part of the Free and Open Source Software Development (MAC0470) course, I contributed to Arkanjo, an open-source plagiarism detection tool written in C++.

Unlike my Linux kernel contribution, which focused on modernizing existing code, this contribution involved implementing a new feature to improve the reliability of Arkanjo’s preprocessing cache.

The work was split into two pull requests. The first introduced version metadata into the cache, allowing the application to know which version generated the preprocessing data. The second built upon that information by verifying cache compatibility before reuse and warning users whenever an outdated cache might produce unreliable results.

During the review process, the implementation also evolved to include a small architectural refactoring that reduced unnecessary dependencies between project modules.


The Problem

Arkanjo performs a preprocessing step before comparing source files. Since preprocessing can be computationally expensive, the generated data is stored on disk so it can be reused in future executions.

Originally, however, the cache only stored information such as the preprocessing directory and execution timestamp.

1
2
3
4
struct PreprocessRunParams {
    fs::path path;
    std::string finished_time;
};

This worked well as long as the preprocessing format remained unchanged. However, after updating Arkanjo, the application had no way to determine whether an existing cache had been generated by an older version.

As a result, users could unknowingly reuse outdated preprocessing data, potentially leading to incorrect similarity results without receiving any indication that something was wrong.

The first step was therefore to extend the cache metadata.

1
2
3
4
5
struct PreprocessRunParams {
    fs::path path;
    std::string finished_time;
    std::string version;
};

Although this modification seems small, it provides the foundation necessary for future compatibility checks.


Exposing the Project Version

Once the cache was capable of storing version information, the next challenge was making the current project version available inside the C++ source code.

The project already defined its version through CMake, so the solution was to expose it as a compile-time definition.

1
2
3
4
target_compile_definitions(project_options INTERFACE
    THIRD_PARTY_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/arkanjo/third-party"
    PROJECT_VERSION="${PROJECT_VERSION}"
)

With this definition available, the preprocessing stage could simply persist the version together with the remaining metadata.

1
2
3
4
5
6
7
std::string version = PROJECT_VERSION;

json data = PreprocessRunParams{
    path,
    time_str,
    version
};

Every cache generated by Arkanjo now carries information about the version that produced it.


Checking Cache Compatibility

Once version information became available, the application could verify whether a cache should still be considered valid before using it.

A new function was introduced for this purpose.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool PreprocessState::is_cache_compatible(
    const std::string& cache_version)
{
    int current_major, current_minor, current_patch;
    int cache_major, cache_minor, cache_patch;
    char dot;

    std::istringstream(PROJECT_VERSION)
        >> current_major >> dot
        >> current_minor >> dot
        >> current_patch;

    std::istringstream(cache_version)
        >> cache_major >> dot
        >> cache_minor >> dot
        >> cache_patch;

    return current_major == cache_major &&
           current_minor == cache_minor;
}

Rather than comparing the version strings directly, the implementation parses the semantic version numbers and compares their individual components.

This makes the comparison more explicit and also leaves room for future compatibility policies should the project decide to support more flexible version ranges.


Warning the User

The compatibility check is performed before the cached similarity table is loaded.

1
2
3
4
5
6
7
8
9
10
11
auto params = PreprocessState::read_current_run_params();

if (!PreprocessState::is_cache_compatible(params.version)) {
    FormatterManager::warn(
        "Cache generated with Arkanjo v" +
        params.version +
        " may be incompatible with the current version v" +
        std::string(PROJECT_VERSION) +
        ". Results may be inaccurate."
    );
}

Previously, Arkanjo would simply load any cache that existed.

Now, users receive an explicit warning whenever cached data originates from an incompatible version, making the application’s behavior considerably safer while still allowing advanced users to proceed if they choose.


An Architectural Improvement

While implementing this feature, an architectural issue became apparent.

The compatibility verification needed access to preprocessing metadata, but that functionality lived inside the commands module, while the cache itself belonged to the base module.

This resulted in the following dependency direction:

1
2
3
4
5
6
7
commands
└── Preprocessor

base
└── SimilarityTable
      ↑
      depends on commands

During code review, one of the maintainers pointed out this unnecessary dependency:

While reviewing this and a few other parts of ArKanjo, I noticed that we have dependencies in the following direction:

commands → base

Ideally, this dependency shouldn’t exist.

Would it make sense to move Preprocessor into base before merging this feature? We could rename it to PreprocessState, which contains the methods:

  • save_current_run_params()
  • read_current_run_params()
  • is_cache_compatible()

I agreed with the suggestion and refactored the implementation accordingly.

Instead of leaving preprocessing state management inside a command implementation, those responsibilities were moved into a dedicated PreprocessState component.

The resulting organization became much cleaner.

1
2
3
4
5
6
base
├── SimilarityTable
└── PreprocessState

commands
└── uses PreprocessState

Besides solving the dependency issue, this separation also better reflects the responsibility of each component: command execution remains in the command layer, while persistent preprocessing metadata is managed independently.


Development and Review Process

Although the feature itself was relatively small, implementing it required touching several independent parts of the project.

The changes involved modifying the cache format, integrating the build system with the source code through CMake, updating serialization routines, implementing compatibility verification, and finally refactoring part of the project’s architecture after the review discussion.

One aspect I particularly appreciated was that the review process went beyond identifying bugs. The maintainers also discussed design decisions and module organization, resulting in an implementation that was cleaner than my initial proposal.

Experiencing this type of review reinforced that contributing to open-source software is not only about making a feature work, but also about improving the overall maintainability of the project.


What I Learned

This contribution highlighted an important lesson about software that stores persistent data: whenever a file format may evolve over time, version metadata becomes essential.

Without it, applications have no reliable way to determine whether previously generated data is still compatible with the current implementation.

Another interesting takeaway was seeing how feature development often uncovers architectural improvements. While implementing cache versioning, the need to access preprocessing metadata exposed an unnecessary dependency between modules. Addressing that issue ultimately produced a cleaner design than the one originally proposed.

Finally, this contribution provided another opportunity to participate in the workflow of a real open-source project, from implementing a feature to discussing architecture and refining the design through code review.


Conclusion

Although the implementation itself was relatively compact, it significantly improved the robustness of Arkanjo’s preprocessing pipeline.

By associating cache files with the version that generated them and validating compatibility before reuse, the application is now able to detect potentially outdated caches and warn users before inaccurate results occur.

Equally important, the contribution also improved the internal organization of the preprocessing code, reducing module coupling and making future maintenance easier.

Overall, this experience reinforced that even relatively small features can have a meaningful impact when they improve both the user experience and the long-term maintainability of an open-source project.

This post is licensed under CC BY 4.0 by the author.