Development Guide
Practices
- test-first development
- protect against regression and make implementing features easy.
- user containers to test more elaborate user interactions
- keep it practical, knowing the Rust compiler already has your back for the mundane things, like unhappy code paths.
- use git itself as reference implementation, and use their test-cases and fixtures where appropriate. At the very least, try to learn from them.
- use libgit2 test fixtures and cases where appropriate, or learn from them.
- safety first
- handle all errors, never
unwrap(). If needed,expect("why"). - provide an error chain and make it easy to understand what went wrong. We use
quick-errorfor non-generic errors andthiserrorwhen generalization is needed.
- handle all errors, never
- Adhere to the stability guide
General
-
async
- library client-side
Don't use it client side, as operations there are usually bound by the CPU and ultra-fast access to memory mapped files. It's no problem to saturate either CPU or the IO system.- Provide
asyncclients as opt-in using feature toggles to help integrating into an existing async codebase.
- Provide
- User Interfaces
- User interfaces can greatly benefit from using async as it's much easier to maintain a responsive UI thread that way thanks to the wonderful future combinators.
blockingcan be used to makeReadandIteratorasync, or move any operation onto a thread which blends it into the async world.- Most operations are fast and 'interrupting' them is as easy as ignoring their result by cancelling their task.
- Long-running operations can be roughly interacted with using
git_features::interrupt::trigger()function, and after a moment of waiting the flag can be unset with the…::uninterrupt()function to allow new long-running operations to work. Every long running operation supports this.
- server-side
Building a pack is CPU and at some point, IO bound, and it makes no sense to use async to handle more connections - git needs a lot of resources and threads will do just fine.- Support async out of the box without locking it into particular traits using conditional complication. This will make integrating into an async codebase easier, which we assume is given on the server side these days.
- usage of
maybe_async- Right not we intentionally only use it in tests to allow one set of test cases to test both blocking and async implementations. This is the only way to prevent drift of otherwise distinct implementations.
- Why not use it to generate blocking versions of traits automatically?
- This would require
maybe_asyncand its dependencies to always be present, increasing compile times. For now we chose a little more code to handle over increasing compile times for everyone. This stance may change later once compile times don't matter that much anymore to allow the removal of code.
- This would require
- library client-side
-
Defaulttrait implementations- These can change only if the effect is contained within the callers process. This means changing the default of a file version is a breaking change.
-
Using the
Progresstrait- When receiving a
Progressimplementation- without calling
add_child(…)then use it directly to communicate progress, leaving control of the name to the caller. However, call.init(…)to configure the iteration. - and when calling
add_child(…)don't use the parent progress instance for anything else.
- without calling
- When receiving a
-
interruption of long-running operations
- Use
git-features::interrupt::*for building support for interruptions of long-running operations only.- It's up to the author to decide how to best integrate it, generally we use a poll-based mechanism to check whether an interrupt flag is set.
- this is a must if…
- …temporary resources like files might otherwise be leaked.
- this is optional but desirable if…
- …there is no leakage otherwise to support user interfaces. They background long-running operations and need them to be cancellable.
- Use
-
prepare for SHA256 support by using
git_hash::ObjectIdandgit_hash::oid- eventually there will be the need to support both Sha1 and Sha256. We anticipate it by using the
Idtype instead of slices or arrays of 20 bytes. This way, eventually we can support multiple hash digest sizes. - Right now it's unclear how Sha256 is going to work in git, so we only support Sha1 for now. It might be an avenue to proactively implement it ahead of time once there is a specification to follow.
- It looks like Git prepares to support it by using compile time, we can support it at runtime though with minimal cost. If needed, we can later remove support using a cargo feature toggle.
- eventually there will be the need to support both Sha1 and Sha256. We anticipate it by using the
-
symbolic links do not exist as far as we are concerned
- in older, probably linux only, git versions symbolic links were used for symbolic references for example. This required special handling in some places. We don't implement that and assume more modern repositories.
-
when to use interior mutability
- in plumbing, do not use it at all but instead provide the mutable part (like caches, buffers) as arguments, pushing their handling entirely to the caller.
- Set on top an optional abstraction that manages the above for you using interior mutability only if part of the mutable state has to be returned as borrow or if otherwise it wouldn't be possible to borrowcheck. Or in other words: start without interior mutability and try to do it the standard way, but switch when needed.
- When using primitives to support interior mutability, use the provided ones and utility functions in
git_features::threading::*exclusively to allow switching between thread-safe and none-threadsafe versions at compile time.- The preferred way of using it is to start out as upgradable reader, and upgrading to write if needed, keeping contention to a minimum.
- If shared ownership is involved, one always needs interior mutability, but may still decide to use an API that requires
&mut selfif locally stored caches are involved. - Types that are not thread-local must be
Sync, but only if thegit-features/parallelis enabled due to the usage ofgit_features::threading::…primitives which won't be thread-safe without the feature.
-
when to use shared ownership
- Use
git_features::threading::OwnSharedparticularly when shared resources supposed to be used by thread-local handles. Going through a wrapper for shared ownership is fast and won't be the bottleneck, as it's only about 16% slower than going through a shared reference on a single core.
- Use
Sha256
A bunch of notes collected to keep track of what's needed to eventually support it
- read
hash-function-transition.txt - support
gpgsig-sha256field - we won't break, but also don't do anything with it (e.g.extra_headers) - support index V3
- Pack file PSRC field
.unwrap() vs .expect(…)
- don't use unwrap, not even in tests. Instead use
quick_error!()orBox<dyn std::error::Error>. - Use
expect(…)as assertion on Options, providing context on why the expectations should hold. Or in other words, answer "This should work because…<expect(…)>"
Examples, Experiments, Porcelain CLI and Plumbing CLI - which does what?
Plumbing vs Porcelain
Both terms are coming from the git implementation itself, even though it won't necessarily point out which commands are plumbing and which
are porcelain.
The term plumbing refers to lower-level, more rarely used commands that complement porcelain by being invoked by it or by hand for certain use
cases.
The term porcelain refers to those with a decent user experience, they are primarily intended for use by humans.
In any case, both types of programs must self-document their capabilities using through the --help flag.
From there, we can derive a few rules to adhere to unless there are good reasons not to:
Plumbing
- does not show any progress or logging output by default
- if supported and logging is enabled, it will show timestamps in UTC
- it does not need a git repository, but instead takes all required information via the command-line
Porcelain
- Provides output to stderr by default to provide progress information. There is no need to allow disabling it, but it shouldn't show up unless the operation takes some time.
- If timestamps are shown, they are in localtime.
- Non-progress information goes to stdout.
Summary
Here is the hierarchy of programs - each level requires more polish and generally work to be done. Experiments are the quickest ways to obtain some insights. Examples are materialized ideas that others can learn from but that don't quite have the polish (or the potential) to move up to plumbing or porcelain. Plumbing is programs for use in scripts, whereas porcelain is for use by humans.
- Experiments
- quick, potentially one-off programs to learn about an aspect of gitoxide potentially in comparison to other implementations like
libgit2. - No need for tests of any kind, but it must compile and be idiomatic Rust and
gitoxide. - Manual commmand-line parsing is OK
- no polish
- make it compile quickly, so no extras
- quick, potentially one-off programs to learn about an aspect of gitoxide potentially in comparison to other implementations like
- Examples
- An implementation of ideas for actual occasional use and the first step towards getting integrated into Porcelain or Plumbing CLIs.
- Proper command-line parsing with Clap.
- No tests or progress.
- High quality Rust code along with idiomatic
gitoxideusage so people can learn from it.
- Plumbing CLI
- Use Clap AND Argh for command-line parsing via feature toggles to allow for tiny builds as plumbing is mostly for scripts.
- Journey tests
- Progress can be turned on using the
--verboseflag, quiet by default. - Examples can be turned into plumbing by adding journey tests and
arghcommand-line parsing, as well as progress.
- Porcelain CLI
- Use Clap for command-line parsing for the best quality CLI experience - it's for the user.
- Journey tests.
- Support for
--quietand--progress. - Verbose by default.
- Examples can be turned into plumbing by adding journey tests and progress.
Maintenance Guide
Utilities to aid in keeping the project fresh and in sync can be found in the Maintenance section of the makefile. Run make to
get an overview.
Creating a release
Run make publish-all to publish all crates in leaf-first order using cargo release based on the currently set version.
For this to work, you have to run cargo release minor|major each time you break the API of a crate but abort it during package verification.
That way, cargo release updates all the dependents for you with the new version, without actually publishing to crates.io.
Which git-version to chase?
Generally, we take the git version installed on ubuntu-latest as the one we stay compatible with (while maintaining backwards compatibility). Certain tests only run on CI, designed to validate certain assumptions still hold against possibly changed git program versions.
This also means that CI may fail despite everything being alright locally, and the fix depends on the problem at hand.
How to update fixtures
Fixtures are created by using a line like this which produces a line we ignore via tail +1 followed by the un-prettified object payload
trailed by a newline.
echo c56a8e7aa92c86c41a923bc760d2dc39e8a31cf7 | git cat-file --batch | tail +2 > fixture
Thus one has to post-process the file by reducing its size by one using truncate -s -1 fixture, removing the newline byte.
Discovery: data consistency and resource usage
Summary
Concurrent writes to packs observable by applications pose a challenge to the current implementation and we need to find ways around that. There may be other limitations related to any resource that changes often, most prominently refs.
Motivation
Use this document to shed light on the entire problem space surrounding data consistency and resource usage of packed objects to aid in finding solutions that are best for various use cases without committing to high costs in one case or another.
Git as database
Databases are well understood and there is common semantics they typically adhere to. The following is an exploration of the various kinds of databases that make up a git repository along with some of their interplay.
Object Databases
In repositories there may be millions to 10th of millions of objects like commits, blobs, trees and tags, and accessing them quickly is relevant to many of git's features.
Loose object database
Each object is stored in a single file on disk, partitions by the first byte of its content hash. All implementations handle it similarly.
Packed object database
Packs are single files containing one or more objects. Delta-compression allows them to be very size-efficient, e.g. 90x compression for the linux kernel.
Packs and the object database is inherently append-only, i.e. objects are never *[0] deleted, allowing concurrent readers to observe a consistent state even in presence of writers. Writers create new pack files and may remove them after adding all changes successfully, without the need for locking.
gitoxides implementation as of 2021-11-19 is known to be unsuitable in the presence of concurrent writes to packs due to its inability to automatically respond
to changed packs on disk if objects cannot be found on the first attempt. Other implementations like libgit2 and canonical git handle this by using
thread-safe interior mutability at the cost of scalability.
gitoxides implementation may be insufficient in that regard, but it shows how read-only data allows to scale across core as well as the CPU architecture allows.
The usage of system resources like file handles is simple but potentially wasteful as all packs are memory-mapped in full immediately. Lazy and partial memory-mapping of packs is used in other implementations. Laziness allows for more efficiency and partial mapping allows to handle big packs on 32 bit systems.
Failure to acquire a memory map due to limits in the amount of open file handles results in an error when initializing the pack database in the gitoxides implementation.
To my knowledge, this is handled similarly in other implementations. All implementations assume there is unlimited memory, but the effect of running out of memory is only
known to me in case of gitoxide which will panic.
[0] deletion is possible but doesn't happen instantly, instead requiring time to pass and calls to git-maintenance and for them to be unreachable, i.e. not used in the entire repository.
Reference databases
References are pointers to objects or other references and are crucial to git as they form the entry points in all git operations. If objects aren't reachable by starting
graph traversal at a reference (or its historical reflog information) it is effectively lost forever, i.e. unreachable.
They may be just a couple to hundreds of thousands of references in a repository which are changed right after new objects are added to the object database.
Loose reference database
References are stored one at a time in files, one reference at a time or multiple ones in a single well known file, packed-refs.
packed-refs is updated during maintenance to keep keep direct references only.
Multiple references can change at the same time, but multiple changes aren't atomic as changes are made a file at a time. All implementations may observe intermediate states where some but not all references are updated.
packed-refs may change during maintenance or upon deletion of references. All implementations cache the packed-refs file but check for a stale cache (i.e. see if file on disk
changed in the mean time) before each use of the cached data.
The database read, i.e. accessing individual reference values or iterating references,
performance is heavily limited by disk-IO when accessing loose files. Handling og packed-refs is speedy even in the presence of hundreds of thousands
of references due to optimizations performed in all implementations.
The reference update/add performance is parallel per reference as long as the set of writers don't overlap in references to change, but bound by disk-IO,
due to writes happening one file at a time. packed-refs is not changed, but typically read to validate write constraints that help with atomicity,
i.e. only change a value if it matches the previously observed one.
Deletions are slow and a worst-case scenario as not only the loose reference(s) will be deleted but potentially the packed-refs file updated if
it contained the (possibly only) copy/ies. An update implies rewriting packed-refs file entirely. During that time it is locked, blocking or failing other writers, forming
a choking point.
gitoxides implementation keeps one packed-refs cache handle to the underlying repository, which costs a file handle for a memory map if the packed-refs file is larger than
32kB, theoretically configurable but currently hardcoded based on the default in git.
Other implementations maintain one per repository instance (libgit2) or one per process (git).
Even the biggest transactions will only additionally open one loose reference file at a time, and close it right after use.
| Operation | read loose file | locks | costs | read packed-refs | concurrency granularity | Limit/Bottleneck |
|---|---|---|---|---|---|---|
| Add/Update | only if comparing to previous value | loose ref | .lock file per reference; write new value; move into place; create intermediate directories as needed; delete empty directories in the way as needed; read packed-refs; possibly append to reflog | only if comparing to previous value and loose reference file isn't present. | per reference | disk-IO |
| Read | always | read loose file; read packed-refs | if loose file didn't exist | n/a | disk-IO | |
| Delete | only if assserting previous value | loose ref; packed-ref | .lock file per reference; delete loose file; delete lock file; .lock for packed-refs, rewrite packed-refs.lock; move packed-refs.lock into place; possibly delete reflog per ref | always | per reference (and packed-refs) | disk-IO (and CPU if packed-refs is involved) |
| maintenance | always all (or by filter) | loose ref; packed-ref | .lock file per reference, read loose reference; .lock for packed-refs; read entire packed-refs; insert loose reference values; write entire altered packed-refs into packed-refs.lock; move packed-refs.lock into place; delete existing loose references and delete their .lock | always | per reference and packed-refs | disk-IO and CPU |
Failures to add/update/delete may occur if the constraint isn't met. It's possible to wait in the presence of a lock file instead of failing immediately, which is beneficial if there is no value constraint. Typically value constraints will be used for safety though, so waiting for a lock to be acquired usually results in failure right after as a change caused by a value mismatch. However, in the presence of deletions, it is always useful to wait for locks as after deletion, the previous value can't be checked anymore causing the operation to succeed.
Races exist do not exist for writers, but do exist for readers as they may observe intermediate states of transactions involving multiple updates.
Semantically, gitoxides implementation of this database is equivalent to the one of git.
Ref-table database
Even though gitoxide doesn't have an implementation yet it's useful to understand its semantics as a possible path forward.
As a major difference to the loose reference database it doesn't operate on user editable files but uses a binary format optimized for read performance and consistency, allowing readers to always have a consistent view. There can be any amount of readers.
All changes like updates, additions or deletions are fully transactional, but there can only be one writer at a time. This has the potential for contention in busy (mono-)repositories as file based locking mechanisms aren't fair and waiting strategies with exponential backoff may cause some writers to wait forever.
Due to the lack of experience with this database details around resource consumption in terms of file handles can't be provided at this time.
Configuration
git employs cascaded configuration files to affect processes running on the repository. Their effect can vary widely, and so can their use in applications handling repositories.
gitoxides implementation currently uses only the repository git configuration file when initializing a repository to correctly determine whether or not it is _bare.
It does not expose git configuration at this time and doesn't use git configuration when repository initialization is complete.
We have no reason to believe that other implementations use git configuration beyond first initialization either or do anything to assure it remains up to date in memory after reading it.
Index
The index is a single file for the purpose acting as a staging area for building and manipulating upcoming commits.
It is created and updated when checking out files into the working tree, and is used to keep track of working tree states while no commit object
was created yet, i.e. during git add ….
gitoxide neither implements it nor it is used in concurrent environments, which is why we exclude it from this discovery.
Known technical problems and their solutions
Before looking at how changes affect data consistency and resource usage affects reliability, let's list all known technical issues thus far.
Solutions aren't always mutually exclusive despite the form of presentation suggesting it.
| Database | Problem | Solution | Benefits | shortcomings | Example Implementation |
|---|---|---|---|---|---|
| pack | 1. initialization | 1. map all packs at once | read-only possible; same latency for all objects | worst case init delay, highest resource usage, some packs may never be read, some applications might run out of system resources early even though they would not have needed all packs. | gitoxide |
| 2. map packs later on object access miss | nearly no init delay, no unnecessary work, resource usage as needed | needs mutability; first access of some objects may be slow | libgit2, git | ||
| pack | 2. file limit hit | 1. fail | read-only possible | gitoxide | |
| 2. free resources and retry, then possibly fail | higher reliability | needs mutability | libgit2 (only on self-imposed limit) | ||
| pack | 3. file handle reduction/avoid hitting file limit | 1. do not exceed internal handle count | some control over file handles | the entire application needs to respect count, needs sync with actual OS imposed limit, no sharing across multiple in-process pack databases | libgit2 (only within pack database) |
| 2. process-wide pooling of memory maps | share packs across multiple repositories instances | pack databases aren't self-contained anymore | |||
| 3. map whole packs at once (instead of non-overlapping parts of them) | Only consume one file handle per pack (as opposed to one per region) | cannot handle packs larger than 4GB on 32bit systems | gitoxide | ||
| 4. Multi-pack index files | A single index can be used N packs, saving N-1 packs for N>1 packs | ||||
| pack | 4. object miss | 1. fail | fast if object misses are expected | incorrect or a burden in user code if miss is due to changed packs | gitoxide |
| 2. lazy-load more packs, retry, refresh known packs, retry, then fail | always correct even in the light of writers | can cause huge amount of extra work if object misses are expected; does not handle problem 5. | libgit2 | ||
| 3. catch error, force a pack refresh, repeat | can work in conjunction with similar shortcomings of loose reference database | needs mutability, burden on the API user; | |||
| 4. writers force an update of the process-wide pool of packs after creating new packs and before updating references with the new objects | high implementation complexity; assumes complete control of one process over git repository, excluding running git-maintenance; new readers aren't allowed for a while until the new pack is placed causing some moments of unresponsiveness/waiting | ||||
| pack | 1. ignore | a chance for occasional object misses | all of them | ||
| 2. retry more than one time | greatly reduced likelyhood of object misses | ||||
| pack | 6.too many (small) packs (i.e. due to pack-receive) reduce lookup performance | 1. explode pack into loose objects (and deal with them separately) | can run in parallel (but is typically bound by max IOP/s) | might take a while if many objects are contained in the pack due to file IOP/s; needs recompresssion and looses delta compression; risk of too many small objects | |
| 2. combine multiple packs into one | keep all benefits of packs; very fast if pack-to-pack copy is used; can run in parallel (but is typically bound by max IOP/s) | combining with big packs takes has to write a lot of data; can be costly if pack delta compression is used | |||
| 3. Just-in-time maintenance after writes | tuned to run just at the right time to run just as much as needed | an implementation isn't trivial as there must only be one maintenance operation per repository at a time, so some queue should be made available to not skip maintenance just because one is running already. | |||
| loose refs | 7. readers of multiple references observe in-between states of transactions which change a subset more than one of these references | 1. switch to ref-table database | switches to shortcomings of ref-table | ||
| 2. repeat reference resolution until it's stable twice in a row | works without ref-table | cost for reference resolution at least doubles if more than one reference is needed; burden on API user | |||
| ref-table | 8. contention of writers around unfair lock | 1. implement process-internal queuing mechanism | minimal cost and high throughput with minimal, in-process synchronization | doesn't help with contention caused by multiple processes | |
| 2. use ref-table partitions | works for in-process and multi-process case, even though it might be slower than queues. | I can't find information about it anymore | |||
| loose objects | 9. too many loose objects reduce overall performance | 1. use packs | needs scheduled or otherwise managed maintenance, and winning strategies depend on the size and business of the repository | ||
| 2. Just-in-time maintenance after writes | tuned to run just at the right time to run just as much as needed | an implementation isn't trivial as there must only be one maintenance operation per repository at a time, so some queue should be made available to not skip maintenance just because one is running already. | |||
| all | 10. disk full/write failure | 1. write temporary files first, with robust auto-removal, move into place when completed; partial transactions are robustly rolled back or stray files aren't discoverable or are valid on their own | gitoxide; git (not libgit2, it leaves partial packs on receive for example) | ||
| loose refs | 11. namespace is stored in database instance, so different Easy handles share it |
1. Have one loose ref database per state (optionally) | A default must be chosen and either one might be surprising to some, i.e. shared namespace as preference depends on the use case entirely, but seems like an unsafe default. |
Amendum problem 5.
Refreshing packs if an object is missed is the current way of handling writes to the pack database. As outlined in how geometric repacking works it can indeed happen that multiple packs are changed which isn't atomic. However, since this will be done in an additive fashion, first adding the new packs based on existing packs and loose objects, and then removing the packs and loose objects they replace, there is no race happening as all objects stay reachable at all times.
Applying changes to repositories while maintaining consistency
A change to any file in a git repository has the potential to affect the processes operating on it, hence it's important to perform them so these observe only valid repository states. For the sake of simplicity, we assume atomicity for all operations, but these still have to be ordered correctly to assure a consistent state.
Adding objects to the database
- add objects
- this is safe as these objects aren't reachable yet.
- object databases are append, only, so no existing state is ever affected.
- adjust reference(s)
- point related references to the entry-points of the newly added objects (typically commits or tags)
- now the new objects can be discovered
Multiple writers will run into conflicts with each other if the references they are handling overlap. Readers will see the object graph from a snapshot of a set of references.
Reorganize objects or references for higher performance
This happens regularly during maintenance operations, which don't alter the repository content but its structure.
- find objects or references to include in the reorganization
- write the reorganized data
- remove the now obsolete data
Multiple of these operations must synchronize or else unpredictable outcomes may occur. Writers may add packs which remain untouched, the same applies to loose objects. Readers will see the object graph from a snapshot of a set of references.
Read references and find objects reachable through them
- obtain object entry-points pointed to by references
- traverse object graph with entry points
Multiple readers are always safe.
It's worth repeating that as long as all of the numbered items are truly atomic, readers will always observe a consistent state in the presence of writers.
What breaks consistency
Packs
If all objects were loose, there would be no issue. However, for the above changes to work one must always be able to see all objects. This isn't the case when packs are involved, as accessing them is too costly to redo the entire pack initialization work each time an object is retrieved (e.g. list packs directory, memory map packs).
Thus packs are cached in order to reuse them each time an object is accessed. Staleness is typically checked if an object isn't present, as it's assumed that all objects accessed are reachable through the object graph, which means they ought to be present, triggering a pack refresh and retrying the retrieval.
This assumption isn't necessarily the case (anymore) due to git features causing partial object repositories:
- depth-pruned clones (i.e.
git clone --depth X) where all commits after a certain topological distance to the latest commit are left out. Running into this case during traversal is cheap despite triggering a refresh to detect that the object is truly not present, as it running a single time while stopping an entire traversal. - partial clones with
git clone --filter=blobwhich can be used to download entire histories but blobs only for a portion of the (usually recent) history. Operations likegit diff, without special precautions, may run into a lot of object misses causing costly refreshes each time.
Any writer that eventually creates a pack may break readers that operate on a stale cache, making strategies for mitigation like the one above mandatory. Such a mitigation, however, requires interior mutability as object access is a read-only operation, which in turn is costly in multi-threaded applications or for applications that don't need it, like CLIs.
Loose References
Writing loose references isn't actually atomic, so readers may observe some references in an old and some in a new state. This isn't always a breaking issue like it is the case for packs, the progam can still operate and is likely to produce correct (enough) outcomes.
Mitigations are possible with careful programming on the API user's side or by using the ref-table database instead.
What reduces reliability
System resources are limited and avoiding waste (along with decent configuration of ulimits and maintenance of disk-space) is one of the ways to keep an application
from failing.
The biggest consumer of system resources most certainly is the pack database due to its cachce of memory mapped pack and index files, and avoiding to duplicate these within a process is paramount.
Use-Cases
We will look at typical access patterns holistically based on various use-cases, look at the problems they would run into and pick their preferred solution that optimizes for efficiency/performance/reduction of waste.
Multi-threaded CLI tool operating on filtered partial repository clone
The tool displays the reference graph, shows differences and creates 'blame' like views on files. As it's operated by the user, other writers are unlikely to happen while it is running.
Scaling near perfectly with added CPU cores and great performance even in the biggest mono-repos are its hallmark.
As the program is is written with missing objects being the default, it gracefully handles and expects such cases. While running in TUI mode, it offers a manual refresh to the user in case they fetched or pulled in the meantime, to refresh the screen and update its object database to make all newly added objects available.
Problems and Solutions
- The program only deals with 1) initialization and 4) object misses, where the latter are expected and handled gracefully. 1) is handled with solution 1., spending time to make all packs available to get the best and most consistent multi-threaded object access performance.
Drawbacks
The program could benefit of using 1.2 instead of 1.1 which could cause exhaustion of file handles despite the user having no interest in evaluating all available objects, but ideally that is possible without loosing performance during multi-threading.
Professional git-hosting mono-repo server with git-maintenance tasks and just-in-time replication
A single server process handles all reads and writes concurrently and receives and sends using the git protocol using an async TCP/IO framework over a custom transport.
All operations calling into gitoxide are unblocked using a thread pool. There is only one repository with a reference namespace for each client repository of which it
contains 50.000, each averaging 5000 objects and 5 references for a total of 250.000.000 objects and 250.000 references. As many of these repositories have PNG files and
binaries checked in, they average to about 25MB of storage space for a total of 1.25TB.
At night there are about 1 send-pack (fetch) and 0.25 send-pack (clone) operations and 0.5 received-pack per second, ramping up to 6 send-pack (fetch) and 1.5 send-pack (clone)
and 3 receive-packs per second. At peak times during the day, these values are 10 times higher due to "push and run go home", causing 30 receive-packs per second for an hour or two.
(assumption is 50.000 * 5 pushes and 10 fetches and 2.5 clones). Most client-side push operations are sending just 1 or 2 commits with a couple of changes, averaging pack sizes
of about 20 objects for a total of 5.000.000 new objects per day or an average of ~208.000 per hour. Only 1 branch is typically updated for 250.000 per day or ~10400 per hour.
After each receive-pack was run and the client connection was informed of success, a background job is started to push the changes using the git protocol to a a sibling server which also takes part in serving clients and also pushes its changes to this server (the writes caused by it are already part of the above measurements).
git-maintenance runs every 5 minutes using the built-in scheduling mechanism to repack references, place loose objects into packs, create and update reverse index caches
and reachability bitmaps and repacks existing packs geometrically. Every 24h it recompresses the newly created packs created during geometric repacking.
Problems and Solutions
- 1.1 pack database - initialization → all at once
- as the server comes up, there is a moment to map all 27 to 28 packs and their indices, totalling up to 56 required file handles, along with maybe twice that many
additional packs in case of many pushes of new repositories happening within the
git-maintenancetime window. - there is traffic at all times with some portions of all objects needed in the course of any 24h period
- as the server comes up, there is a moment to map all 27 to 28 packs and their indices, totalling up to 56 required file handles, along with maybe twice that many
additional packs in case of many pushes of new repositories happening within the
- 2.1 pack database - file limit hit → fail
- The worst case scenario for the amount of required file handles can be estimated and overcommitted.
- Update the
ulimitif there is not enough available handles for peak operations.
- 3.2 & 3.3 pack database - file handle reduction/avoid hitting file limit → process wide memory map pooling & mapping entire packs/indices at once
- Note that multipack indices are related to maximizing object-count performance in conjunction with bitmaps, which is a separate topic, but in short: yes, want that, too :)
- 4.3 & 4.4 pack database - object miss → catch error, refresh packs, retry & writers force update shared memory map pool
- we read references at well known times and assure their objects exists right away. If not, a forced (but efficient) pack refresh should make the new objects available.
- For readers this mostly happens if external processes change the pack database, like git-maintenance, as writers keep the view on memory mapped packs fresh (efficiently) by informing about the exact changes to avoid directory scanning.
Lines 1–500 of 712. Load more
# Development Guide ## Practices * **test-first development** * protect against regression and make implementing features easy. * user containers to test more elaborate user interactions * keep it practical, knowing the Rust compiler already has your back for the mundane things, like unhappy code paths. * *use git itself* as reference implementation, and use their test-cases and fixtures where appropriate. At the very least, try to learn from them. * *use libgit2* test fixtures and cases where appropriate, or learn from them. * **safety first** * handle all errors, never `unwrap()`. If needed, `expect("why")`. * provide an error chain and make it easy to understand what went wrong. We use `quick-error` for non-generic errors and `thiserror` when generalization is needed. * Adhere to the [stability guide](https://github.com/Byron/gitoxide/blob/main/STABILITY.md) ## General * **async** * **library client-side** * ~~Don't use it client side, as operations there are usually bound by the CPU and ultra-fast access to memory mapped files. It's no problem to saturate either CPU or the IO system.~~ * Provide `async` clients as opt-in using feature toggles to help integrating into an existing async codebase. * **User Interfaces** * User interfaces can greatly benefit from using async as it's much easier to maintain a responsive UI thread that way thanks to the wonderful future combinators. * `blocking` can be used to make `Read` and `Iterator` async, or move any operation onto a thread which blends it into the async world. * Most operations are fast and 'interrupting' them is as easy as ignoring their result by cancelling their task. * Long-running operations can be roughly interacted with using `git_features::interrupt::trigger()` function, and after a moment of waiting the flag can be unset with the `…::uninterrupt()` function to allow new long-running operations to work. Every long running operation supports this. * **server-side** * ~~Building a pack is CPU and at some point, IO bound, and it makes no sense to use async to handle more connections - git needs a lot of resources and threads will do just fine.~~ * Support async out of the box without locking it into particular traits using conditional complication. This will make integrating into an async codebase easier, which we assume is given on the server side _these days_. * **usage of `maybe_async`** * Right not we intentionally only use it in tests to allow one set of test cases to test both blocking and async implementations. This is the only way to prevent drift of otherwise distinct implementations. * **Why not use it to generate blocking versions of traits automatically?** * This would require `maybe_async` and its dependencies to always be present, increasing compile times. For now we chose a little more code to handle over increasing compile times for everyone. This stance may change later once compile times don't matter that much anymore to allow the removal of code. * **`Default` trait implementations** * These can change only if the effect is contained within the callers process. This means **changing the default of a file version** is a **breaking change**. * **Using the `Progress` trait** * When receiving a `Progress` implementation * without calling `add_child(…)` then use it directly to communicate progress, leaving control of the name to the caller. However, call `.init(…)` to configure the iteration. * and when calling `add_child(…)` don't use the parent progress instance for anything else. * **interruption of long-running operations** * Use `git-features::interrupt::*` for building support for interruptions of long-running operations only. * It's up to the author to decide how to best integrate it, generally we use a poll-based mechanism to check whether an interrupt flag is set. * **this is a must if…** * …temporary resources like files might otherwise be leaked. * **this is optional but desirable if…** * …there is no leakage otherwise to support user interfaces. They background long-running operations and need them to be cancellable. * **prepare for SHA256 support by using `git_hash::ObjectId` and `git_hash::oid`** * eventually there will be the need to support both Sha1 and Sha256. We anticipate it by using the `Id` type instead of slices or arrays of 20 bytes. This way, eventually we can support multiple hash digest sizes. * Right now it's unclear how Sha256 is going to work in git, so we only support Sha1 for now. It might be an avenue to proactively implement it ahead of time once there is a specification to follow. * It looks like Git prepares to support it by using compile time, we can support it at runtime though with minimal cost. If needed, we can later remove support using a cargo feature toggle. * **symbolic links do not exist** as far as we are concerned * in older, probably linux only, git versions symbolic links were used for symbolic references for example. This required special handling in some places. We don't implement that and assume more modern repositories. * **when to use interior mutability** - in **plumbing**, do not use it at all but instead provide the mutable part (like caches, buffers) as arguments, pushing their handling entirely to the caller. - Set on top an optional abstraction that manages the above for you using **interior mutability only if part of the mutable state has to be returned as borrow** or if otherwise it wouldn't be possible to borrowcheck. Or in other words: start without interior mutability and try to do it the standard way, but switch when needed. - When using primitives to support interior mutability, use the provided ones and utility functions in `git_features::threading::*` exclusively to allow switching between thread-safe and none-threadsafe versions at compile time. - The preferred way of using it is to start out as upgradable reader, and upgrading to write if needed, keeping contention to a minimum. - If _shared ownership_ is involved, one always needs _interior mutability_, but may still decide to use an API that requires `&mut self` if locally stored caches are involved. - Types that are not thread-local must be `Sync`, but only if the `git-features/parallel` is enabled due to the usage of `git_features::threading::…` primitives which won't be thread-safe without the feature. * **when to use shared ownership** - Use `git_features::threading::OwnShared` particularly when shared resources supposed to be used by thread-local handles. Going through a wrapper for shared ownership is fast and won't be the bottleneck, as it's only about 16% slower than going through a shared reference on a single core. ## Sha256 A bunch of notes collected to keep track of what's needed to eventually support it * read `hash-function-transition.txt` * [x] support `gpgsig-sha256` field - we won't break, but also don't do anything with it (e.g. `extra_headers`) * [ ] support index V3 * [ ] Pack file PSRC field ## `.unwrap()` vs `.expect(…)` * don't use unwrap, not even in tests. Instead use `quick_error!()` or `Box<dyn std::error::Error>`. * Use `expect(…)` as assertion on Options, providing context on *why* the expectations should hold. Or in other words, answer "This should work _because_…<expect(…)>" ## Examples, Experiments, Porcelain CLI and Plumbing CLI - which does what? ### Plumbing vs Porcelain Both terms are coming from the `git` implementation itself, even though it won't necessarily point out which commands are plumbing and which are porcelain. The term *plumbing* refers to lower-level, more rarely used commands that complement porcelain by being invoked by it or by hand for certain use cases. The term *porcelain* refers to those with a decent user experience, they are primarily intended for use by humans. In any case, both types of programs must self-document their capabilities using through the `--help` flag. From there, we can derive a few rules to adhere to unless there are good reasons not to: #### Plumbing * does not show any progress or logging output by default * if supported and logging is enabled, it will show timestamps in UTC * it does not need a git repository, but instead takes all required information via the command-line #### Porcelain * Provides output to stderr by default to provide progress information. There is no need to allow disabling it, but it shouldn't show up unless the operation takes some time. * If timestamps are shown, they are in localtime. * Non-progress information goes to stdout. #### Summary Here is the hierarchy of programs - each level requires more polish and generally work to be done. _Experiments_ are the quickest ways to obtain some insights. _Examples_ are materialized ideas that others can learn from but that don't quite have the polish (or the potential) to move up to _plumbing_ or _porcelain_. _Plumbing_ is programs for use in scripts, whereas _porcelain_ is for use by humans. * **Experiments** * quick, potentially one-off programs to learn about an aspect of gitoxide potentially in comparison to other implementations like `libgit2`. * No need for tests of any kind, but it must compile and be idiomatic Rust and `gitoxide`. * Manual commmand-line parsing is OK * no polish * make it compile quickly, so no extras * **Examples** * An implementation of ideas for actual occasional use and the first step towards getting integrated into Porcelain or Plumbing CLIs. * Proper command-line parsing with Clap. * No tests or progress. * High quality Rust code along with idiomatic `gitoxide` usage so people can learn from it. * **Plumbing CLI** * Use Clap AND Argh for command-line parsing via feature toggles to allow for tiny builds as plumbing is mostly for scripts. * Journey tests * Progress can be turned on using the `--verbose` flag, quiet by default. * Examples can be turned into plumbing by adding journey tests and `argh` command-line parsing, as well as progress. * **Porcelain CLI** * Use Clap for command-line parsing for the best quality CLI experience - it's for the user. * Journey tests. * Support for `--quiet` and `--progress`. * Verbose by default. * Examples can be turned into plumbing by adding journey tests and progress. # Maintenance Guide Utilities to aid in keeping the project fresh and in sync can be found in the `Maintenance` section of the `makefile`. Run `make` to get an overview. ## Creating a release Run `make publish-all` to publish all crates in leaf-first order using `cargo release` based on the currently set version. For this to work, you have to run `cargo release minor|major` each time you break the API of a crate but abort it during package verification. That way, `cargo release` updates all the dependents for you with the new version, without actually publishing to crates.io. ## Which git-version to chase? Generally, we take the git version installed on ubuntu-latest as the one we stay compatible with (_while maintaining backwards compatibility_). Certain tests only run on CI, designed to validate certain assumptions still hold against possibly changed git program versions. This also means that CI may fail despite everything being alright locally, and the fix depends on the problem at hand. ## How to update fixtures Fixtures are created by using a line like this which produces a line we ignore via `tail +1` followed by the un-prettified object payload trailed by a newline. ```sh echo c56a8e7aa92c86c41a923bc760d2dc39e8a31cf7 | git cat-file --batch | tail +2 > fixture ``` Thus one has to post-process the file by reducing its size by one using `truncate -s -1 fixture`, **removing the newline byte**. # Discovery: data consistency and resource usage ## Summary Concurrent writes to packs observable by applications pose a challenge to the current implementation and we need to find ways around that. There may be other limitations related to any resource that changes often, most prominently refs. ## Motivation Use this document to shed light on the entire problem space surrounding data consistency and resource usage of packed objects to aid in finding solutions that are best for various use cases without committing to high costs in one case or another. ## Git as database Databases are well understood and there is common semantics they typically adhere to. The following is an exploration of the various kinds of databases that make up a git repository along with some of their interplay. ### Object Databases In repositories there may be millions to 10th of millions of objects like commits, blobs, trees and tags, and accessing them quickly is relevant to many of git's features. #### Loose object database Each object is stored in a single file on disk, partitions by the first byte of its content hash. All implementations handle it similarly. #### Packed object database Packs are single files containing one or more objects. Delta-compression allows them to be very size-efficient, e.g. 90x compression for the linux kernel. Packs and the object database is inherently append-only, i.e. objects are never *[0] deleted, allowing concurrent readers to observe a consistent state even in presence of writers. Writers create new pack files and may remove them after adding all changes successfully, without the need for locking. `gitoxide`s implementation as of 2021-11-19 is known to be unsuitable in the presence of concurrent writes to packs due to its inability to automatically respond to changed packs on disk if objects cannot be found on the first attempt. Other implementations like `libgit2` and canonical `git` handle this by using thread-safe interior mutability at the cost of scalability. `gitoxide`s implementation may be insufficient in that regard, but it shows how read-only data allows to scale across core as well as the CPU architecture allows. The usage of system resources like file handles is simple but potentially wasteful as all packs are memory-mapped in full immediately. Lazy and partial memory-mapping of packs is used in other implementations. Laziness allows for more efficiency and partial mapping allows to handle big packs on 32 bit systems. Failure to acquire a memory map due to limits in the amount of open file handles results in an error when initializing the pack database in the `gitoxide`s implementation. To my knowledge, this is handled similarly in other implementations. All implementations assume there is unlimited memory, but the effect of running out of memory is only known to me in case of `gitoxide` which will panic. [0] deletion is possible but doesn't happen instantly, instead requiring time to pass and calls to git-maintenance and for them to be unreachable, i.e. not used in the entire repository. ### Reference databases References are pointers to objects or other references and are crucial to `git` as they form the entry points in all git operations. If objects aren't reachable by starting graph traversal at a reference (or its historical reflog information) it is effectively lost forever, i.e. unreachable. They may be just a couple to hundreds of thousands of references in a repository which are changed right after new objects are added to the object database. #### Loose reference database References are stored one at a time in files, one reference at a time or multiple ones in a single well known file, `packed-refs`. `packed-refs` is updated during maintenance to keep keep direct references only. Multiple references can change at the same time, but multiple changes aren't atomic as changes are made a file at a time. All implementations may observe intermediate states where some but not all references are updated. `packed-refs` may change during maintenance or upon deletion of references. All implementations cache the `packed-refs` file but check for a stale cache (i.e. see if file on disk changed in the mean time) before each use of the cached data. The database read, i.e. accessing individual reference values or iterating references, performance is heavily limited by disk-IO when accessing loose files. Handling og `packed-refs` is speedy even in the presence of hundreds of thousands of references due to optimizations performed in all implementations. The reference update/add performance is parallel per reference as long as the set of writers don't overlap in references to change, but bound by disk-IO, due to writes happening one file at a time. `packed-refs` is not changed, but typically read to validate write constraints that help with atomicity, i.e. only change a value if it matches the previously observed one. Deletions are slow and a worst-case scenario as not only the loose reference(s) will be deleted but potentially the `packed-refs` file updated if it contained the (possibly only) copy/ies. An update implies rewriting `packed-refs` file entirely. During that time it is locked, blocking or failing other writers, forming a choking point. `gitoxide`s implementation keeps one `packed-refs` cache handle to the underlying repository, which costs a file handle for a memory map if the `packed-refs` file is larger than 32kB, theoretically configurable but currently hardcoded based on the default in `git`. Other implementations maintain one per repository instance (libgit2) or one per process (git). Even the biggest transactions will only additionally open one loose reference file at a time, and close it right after use. | **Operation** | **read loose file** | **locks** | **costs** | **read packed-refs** | **concurrency granularity** | **Limit/Bottleneck** | |-----------------|-------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|---------------------------------|----------------------------------------------| | **Add/Update** | only if comparing to previous value | loose ref | .lock file per reference; write new value; move into place; create intermediate directories as needed; delete empty directories in the way as needed; read packed-refs; possibly append to reflog | only if comparing to previous value and loose reference file isn't present. | per reference | disk-IO | | **Read** | always | | read loose file; read packed-refs | if loose file didn't exist | n/a | disk-IO | | **Delete** | only if assserting previous value | loose ref; packed-ref | .lock file per reference; delete loose file; delete lock file; .lock for packed-refs, rewrite packed-refs.lock; move packed-refs.lock into place; possibly delete reflog per ref | always | per reference (and packed-refs) | disk-IO (and CPU if packed-refs is involved) | | **maintenance** | always all (or by filter) | loose ref; packed-ref | .lock file per reference, read loose reference; .lock for packed-refs; read entire packed-refs; insert loose reference values; write entire altered packed-refs into packed-refs.lock; move packed-refs.lock into place; delete existing loose references and delete their .lock | always | per reference and packed-refs | disk-IO and CPU | Failures to add/update/delete may occur if the constraint isn't met. It's possible to wait in the presence of a lock file instead of failing immediately, which is beneficial if there is no value constraint. Typically value constraints will be used for safety though, so waiting for a lock to be acquired usually results in failure right after as a change caused by a value mismatch. However, in the presence of deletions, it is always useful to wait for locks as after deletion, the previous value can't be checked anymore causing the operation to succeed. Races exist do not exist for writers, but do exist for readers as they may observe intermediate states of transactions involving multiple updates. Semantically, `gitoxide`s implementation of this database is equivalent to the one of `git`. #### Ref-table database Even though `gitoxide` doesn't have an implementation yet it's useful to understand its semantics as a possible path forward. As a major difference to the _loose reference database_ it doesn't operate on user editable files but uses a binary format optimized for read performance and consistency, allowing readers to always have a consistent view. There can be any amount of readers. All changes like updates, additions or deletions are fully transactional, but there can only be one writer at a time. This has the potential for contention in busy (mono-)repositories as file based locking mechanisms aren't fair and waiting strategies with exponential backoff may cause some writers to wait forever. Due to the lack of experience with this database details around resource consumption in terms of file handles can't be provided at this time. ### Configuration `git` employs cascaded configuration files to affect processes running on the repository. Their effect can vary widely, and so can their use in applications handling repositories. `gitoxide`s implementation currently uses only the repository git configuration file when initializing a repository to correctly determine whether or not it is _bare. It does not expose git configuration at this time and doesn't use git configuration when repository initialization is complete. We have no reason to believe that other implementations use git configuration beyond first initialization either or do anything to assure it remains up to date in memory after reading it. ### Index The `index` is a single file for the purpose acting as a staging area for building and manipulating upcoming commits. It is created and updated when checking out files into the working tree, and is used to keep track of working tree states while no commit object was created yet, i.e. during `git add …`. `gitoxide` neither implements it nor it is used in concurrent environments, which is why we exclude it from this discovery. ## Known technical problems and their solutions Before looking at how changes affect data consistency and resource usage affects reliability, let's list all known technical issues thus far. Solutions aren't always mutually exclusive despite the form of presentation suggesting it. | **Database** | **Problem** | **Solution** | **Benefits** | **shortcomings** | **Example Implementation** | |-------------------|----------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------| | **pack** | **1. initialization** | 1. map all packs at once | read-only possible; same latency for all objects | worst case init delay, highest resource usage, some packs may never be read, some applications might run out of system resources early even though they would not have needed all packs. | gitoxide | | | | 2. map packs later on object access miss | nearly no init delay, no unnecessary work, resource usage as needed | needs mutability; first access of some objects may be slow | libgit2, git | | **pack** | **2. file limit hit** | 1. fail | read-only possible | | gitoxide | | | | 2. free resources and retry, then possibly fail | higher reliability | needs mutability | libgit2 (only on self-imposed limit) | | **pack** | **3. file handle reduction/avoid hitting file limit** | 1. do not exceed internal handle count | some control over file handles | the entire application needs to respect count, needs sync with actual OS imposed limit, no sharing across multiple in-process pack databases | libgit2 (only within pack database) | | | | 2. process-wide pooling of memory maps | share packs across multiple repositories instances | pack databases aren't self-contained anymore | | | | | 3. map whole packs at once (instead of non-overlapping parts of them) | Only consume one file handle per pack (as opposed to one per region) | cannot handle packs larger than 4GB on 32bit systems | gitoxide | | | | 4. Multi-pack index files | A single index can be used N packs, saving N-1 packs for N>1 packs | | | | **pack** | **4. object miss** | 1. fail | fast if object misses are expected | incorrect or a burden in user code if miss is due to changed packs | gitoxide | | | | 2. lazy-load more packs, retry, refresh known packs, retry, then fail | always correct even in the light of writers | can cause huge amount of extra work if object misses are expected; does not handle problem 5. | libgit2 | | | | 3. catch error, force a pack refresh, repeat | can work in conjunction with similar shortcomings of loose reference database | needs mutability, burden on the API user; | | | | | 4. writers force an update of the process-wide pool of packs after creating new packs and before updating references with the new objects | | high implementation complexity; assumes complete control of one process over git repository, excluding running git-maintenance; new readers aren't allowed for a while until the new pack is placed causing some moments of unresponsiveness/waiting | | | **pack** | ~~5. race when creating/altering more than a pack at a time~~ | 1. ignore | | a chance for occasional object misses | all of them | | | | 2. retry more than one time | greatly reduced likelyhood of object misses | | | | **pack** | **6.too many (small) packs (i.e. due to pack-receive) reduce lookup performance** | 1. explode pack into loose objects (and deal with them separately) | can run in parallel (but is typically bound by max IOP/s) | might take a while if many objects are contained in the pack due to file IOP/s; needs recompresssion and looses delta compression; risk of too many small objects | | | | | 2. combine multiple packs into one | keep all benefits of packs; very fast if pack-to-pack copy is used; can run in parallel (but is typically bound by max IOP/s) | combining with big packs takes has to write a lot of data; can be costly if pack delta compression is used | | | | | 3. Just-in-time maintenance after writes | tuned to run just at the right time to run just as much as needed | an implementation isn't trivial as there must only be one maintenance operation per repository at a time, so some queue should be made available to not skip maintenance just because one is running already. | | | **loose refs** | **7. readers of multiple references observe in-between states of transactions which change a subset more than one of these references** | 1. switch to ref-table database | | switches to shortcomings of ref-table | | | | | 2. repeat reference resolution until it's stable twice in a row | works without ref-table | cost for reference resolution at least doubles if more than one reference is needed; burden on API user | | | **ref-table** | **8. contention of writers around unfair lock** | 1. implement process-internal queuing mechanism | minimal cost and high throughput with minimal, in-process synchronization | doesn't help with contention caused by multiple processes | | | | | 2. use ref-table partitions | works for in-process and multi-process case, even though it might be slower than queues. | I can't find information about it anymore | | | **loose objects** | **9. too many loose objects reduce overall performance** | 1. use packs | | needs scheduled or otherwise managed maintenance, and winning strategies depend on the size and business of the repository | | | | | 2. Just-in-time maintenance after writes | tuned to run just at the right time to run just as much as needed | an implementation isn't trivial as there must only be one maintenance operation per repository at a time, so some queue should be made available to not skip maintenance just because one is running already. | | | **all** | **10. disk full/write failure** | 1. write temporary files first, with robust auto-removal, move into place when completed; partial transactions are robustly rolled back or stray files aren't discoverable or are valid on their own | | | gitoxide; git (not libgit2, it leaves partial packs on receive for example) | | **loose refs** | **11. namespace is stored in database instance, so different `Easy` handles share it** | 1. Have one loose ref database per state (optionally) | | A default must be chosen and either one might be surprising to some, i.e. shared namespace as preference depends on the use case entirely, but seems like an unsafe default. | | ### Amendum problem 5. Refreshing packs if an object is missed is the current way of handling writes to the pack database. As outlined in how [geometric repacking works](https://github.blog/2021-04-29-scaling-monorepo-maintenance/#geometric-repacking) it can indeed happen that multiple packs are changed which isn't atomic. However, since this will be done in an additive fashion, first adding the new packs based on existing packs and loose objects, and then removing the packs and loose objects they replace, there is no race happening as all objects stay reachable at all times. ## Applying changes to repositories while maintaining consistency A change to any file in a git repository has the potential to affect the processes operating on it, hence it's important to perform them so these observe only valid repository states. For the sake of simplicity, we assume atomicity for all operations, but these still have to be ordered correctly to assure a consistent state. **Adding objects to the database** 1. add objects * this is safe as these objects aren't reachable yet. * object databases are append, only, so no existing state is ever affected. 2. adjust reference(s) * point related references to the entry-points of the newly added objects (typically commits or tags) * now the new objects can be discovered **Multiple writers** will run into conflicts with each other if the references they are handling overlap. **Readers** will see the object graph from a snapshot of a set of references. **Reorganize objects or references for higher performance** This happens regularly during maintenance operations, which don't alter the repository content but its structure. 1. find objects or references to include in the reorganization 2. write the reorganized data 3. remove the now obsolete data Multiple of these operations must synchronize or else unpredictable outcomes may occur. **Writers** may add packs which remain untouched, the same applies to loose objects. **Readers** will see the object graph from a snapshot of a set of references. **Read references and find objects reachable through them** 1. obtain object entry-points pointed to by references 2. traverse object graph with entry points Multiple readers are always safe. It's worth repeating that as long as all of the numbered items are truly atomic, readers will always observe a consistent state in the presence of writers. ### What breaks consistency #### Packs If all objects were loose, there would be no issue. However, for the above changes to work one must always be able to see all objects. This isn't the case when packs are involved, as accessing them is too costly to redo the entire pack initialization work each time an object is retrieved (e.g. list packs directory, memory map packs). Thus packs are cached in order to reuse them each time an object is accessed. Staleness is typically checked if an object isn't present, as it's assumed that all objects accessed are reachable through the object graph, which means they ought to be present, triggering a pack refresh and retrying the retrieval. This assumption isn't necessarily the case (_anymore_) due to git features causing partial object repositories: - depth-pruned clones (i.e. `git clone --depth X`) where all commits after a certain topological distance to the latest commit are left out. Running into this case during traversal is cheap despite triggering a refresh to detect that the object is truly not present, as it running a single time while stopping an entire traversal. - partial clones with `git clone --filter=blob` which can be used to download entire histories but blobs only for a portion of the (usually recent) history. Operations like `git diff`, without special precautions, may run into a lot of object misses causing costly refreshes each time. Any writer that eventually creates a pack may break readers that operate on a stale cache, making strategies for mitigation like the one above mandatory. Such a mitigation, however, requires interior mutability as object access is a read-only operation, which in turn is costly in multi-threaded applications or for applications that don't need it, like CLIs. #### Loose References Writing loose references isn't actually atomic, so readers may observe some references in an old and some in a new state. This isn't always a breaking issue like it is the case for packs, the progam can still operate and is likely to produce correct (enough) outcomes. Mitigations are possible with careful programming on the API user's side or by using the `ref-table` database instead. ### What reduces reliability System resources are limited and avoiding waste (along with decent configuration of `ulimits` and maintenance of disk-space) is one of the ways to keep an application from failing. The biggest consumer of system resources most certainly is the pack database due to its cachce of memory mapped pack and index files, and avoiding to duplicate these within a process is paramount. ## Use-Cases We will look at typical access patterns holistically based on various use-cases, look at the problems they would run into and pick their preferred solution that optimizes for efficiency/performance/reduction of waste. ### Multi-threaded CLI tool operating on filtered partial repository clone The tool displays the reference graph, shows differences and creates 'blame' like views on files. As it's operated by the user, other writers are unlikely to happen while it is running. Scaling near perfectly with added CPU cores and great performance even in the biggest mono-repos are its hallmark. As the program is is written with missing objects being the default, it gracefully handles and expects such cases. While running in TUI mode, it offers a manual refresh to the user in case they fetched or pulled in the meantime, to refresh the screen and update its object database to make all newly added objects available. **Problems and Solutions** * The program only deals with _1) initialization_ and _4) object misses_, where the latter are expected and handled gracefully. 1) is handled with solution 1., spending time to make all packs available to get the best and most consistent multi-threaded object access performance. **Drawbacks** The program could benefit of using 1.2 instead of 1.1 which could cause exhaustion of file handles despite the user having no interest in evaluating all available objects, but ideally that is possible without loosing performance during multi-threading. ### Professional git-hosting mono-repo server with git-maintenance tasks and just-in-time replication A single server process handles all reads and writes concurrently and receives and sends using the git protocol using an async TCP/IO framework over a custom transport. All operations calling into `gitoxide` are unblocked using a thread pool. There is only one repository with a reference namespace for each client repository of which it contains 50.000, each averaging 5000 objects and 5 references for a total of 250.000.000 objects and 250.000 references. As many of these repositories have PNG files and binaries checked in, they average to about 25MB of storage space for a total of 1.25TB. At night there are about 1 send-pack (fetch) and 0.25 send-pack (clone) operations and 0.5 received-pack per second, ramping up to 6 send-pack (fetch) and 1.5 send-pack (clone) and 3 receive-packs per second. At peak times during the day, these values are 10 times higher due to "push and ~~run~~ go home", causing 30 receive-packs per second for an hour or two. _(assumption is 50.000 * 5 pushes and 10 fetches and 2.5 clones)_. Most client-side push operations are sending just 1 or 2 commits with a couple of changes, averaging pack sizes of about 20 objects for a total of 5.000.000 new objects per day or an average of ~208.000 per hour. Only 1 branch is typically updated for 250.000 per day or ~10400 per hour. After each receive-pack was run and the client connection was informed of success, a background job is started to push the changes using the git protocol to a a sibling server which also takes part in serving clients and also pushes its changes to this server (the writes caused by it are already part of the above measurements). `git-maintenance` runs every 5 minutes using the built-in scheduling mechanism to repack references, place loose objects into packs, create and update reverse index caches and reachability bitmaps and repacks existing packs geometrically. Every 24h it recompresses the newly created packs created during geometric repacking. **Problems and Solutions** * **1.1** pack database - initialization → all at once - as the server comes up, there is a moment to map all 27 to 28 packs and their indices, totalling up to 56 required file handles, along with maybe twice that many additional packs in case of many pushes of new repositories happening within the `git-maintenance` time window. - there is traffic at all times with some portions of all objects needed in the course of any 24h period * **2.1** pack database - file limit hit → fail - The worst case scenario for the amount of required file handles can be estimated and overcommitted. - Update the `ulimit` if there is not enough available handles for peak operations. * **3.2 & 3.3** pack database - file handle reduction/avoid hitting file limit → process wide memory map pooling & mapping entire packs/indices at once - Note that multipack indices are related to maximizing object-count performance in conjunction with bitmaps, which is a separate topic, but in short: yes, want that, too :) * **4.3 & 4.4** pack database - object miss → catch error, refresh packs, retry & writers force update shared memory map pool - we read references at well known times and assure their objects exists right away. If not, a forced (but efficient) pack refresh should make the new objects available. - For readers this mostly happens if external processes change the pack database, like git-maintenance, as writers keep the view on memory mapped packs fresh (efficiently) by informing about the exact changes to avoid directory scanning.