Scientist.NET Documentation

repository·master·Indexed 23 days ago

https://github.com/scientistproject/scientist.net

A .NET port of the GitHub Scientist library used for safely refactoring critical code paths. It allows developers to compare an existing implementation (control) against a new implementation (candidate) under real-world load, monitoring for performance, correctness, and exceptions without changing application logic.

Tokens
4.6K
Snippets
15
Records
21
Agent score
31%

What's inside Scientist.NET

  1. Core concepts: Control vs Candidate

    master

    Scientist.NET uses two primary abstractions to facilitate safe refactoring:

    • Control: The existing, proven implementation. It is defined using the experiment.Use() method. The result of the Use block is what is ultimately returned to the caller of Scientist.Science<T>.
    • Candidate: The new, refactored implementation being tested. It is defined using the experiment.Try() method. Scientist monitors the candidate for performance, correctness (result matching), and exceptions, but its output is not returned to the caller.

    If no Try blocks are declared within an experiment, the Scientist machinery is bypassed and the control value is returned directly.

  2. How to perform an experiment with Scientist.Science<T>

    master

    Use Scientist.Science<T> to wrap a critical code path where you want to compare an existing implementation (the control) against a new implementation (the candidate).

    1. Call Scientist.Science<T>(experimentName, experiment => ...).
    2. Inside the experiment block, use experiment.Use(() => ...) to wrap the original, existing behavior (the control).
    3. Use experiment.Try(() => ...) to wrap the refactored or new behavior (the candidate).

    Scientist.Science<T> always returns the result of the Use block (the control value), ensuring that the experiment does not change the application's logic. Behind the scenes, it handles execution order randomization, duration measurement, result comparison, and exception recording for the candidate.

    using GitHub;
    
    ...
    
    public bool CanAccess(IUser user)
    {
        return Scientist.Science<bool>("widget-permissions", experiment =>
        {
            experiment.Use(() => IsCollaborator(user)); // old way
            experiment.Try(() => HasAccess(user)); // new way
        }); // returns the control value
    }
  3. Ignore experiment results while still testing code paths

    master

    If you only care about side effects (like timing or ensuring a new code path doesn't throw exceptions) rather than value equality, you can instruct Scientist.NET to ignore the results. This is useful when using the Enabled method to incrementally roll out code.

    To disregard values entirely while still logging exceptions, use Ignore((x, y) => true) or the more efficient Compare((x, y) => true).

  4. Run multiple candidate alternatives simultaneously

    master

    While it is generally recommended to test one alternative at a time to ensure isolation and clear reporting, you can test multiple candidates by providing names to Try blocks. This allows you to compare several different implementations within a single experiment block.

    public bool CanAccess(IUser user)
    {
        return Scientist.Science<bool>("widget-permissions", experiment =>
        {
            experiment.Use(() => IsCollaborator(user));
            experiment.Try("api", () => HasAccess(user));
            experiment.Try("raw-sql", () => HasAccessSql(user));
        });
    }
  5. Customize the execution order of behaviors

    master

    By default, Scientist.NET randomizes the order in which behaviors (the control and the candidates) are executed. If you need to control this order, use the UseCustomOrdering method.

    You can use the built-in Ordering algorithms:

    • Ordering.Random (Default)
    • Ordering.ControlFirst
    • Ordering.ControlLast

    Alternatively, you can provide your own ordering function that accepts an IReadOnlyList<INamedBehavior<T>> and returns an ordered list.

    // Using a built-in ordering
    scientist.Experiment<int>(experimentName, experiment =>
    {
        experiment.UseCustomOrdering(Ordering.ControlFirst);
        // ...
    });
    
    // Using a custom ordering function
    private static int _seed = 123;
    
    public static IReadOnlyList<INamedBehavior<T>> SeededExperimentOrderer<T>(IReadOnlyList<INamedBehavior<T>> behaviors)
    {
        var random = new Random(_seed);
        return behaviors.OrderBy(_ => random.Next()).ToList();
    }
    
    // ...
    
    scientist.Experiment<int>(experimentName, experiment =>
    {
        experiment.UseCustomOrdering(SeededExperimentOrderer);
        // ...
    });
  6. Override default comparison logic with Compare

    master

    Scientist uses the == operator to compare control and candidate values by default. If you need custom comparison logic (e.g., comparing specific properties of an object), use the experiment.Compare method within the science block.

    public IUser GetCurrentUser(string hash)
    {
        return Scientist.Science<IUser>("get-current-user", experiment =>
        {
            experiment.Compare((x, y) => x.Name == y.Name);
    
            experiment.Use(() => LookupUser(hash));
            experiment.Try(() => RetrieveUser(hash));
        });
    }
  7. Add metadata to experiments using AddContext

    master

    Use experiment.AddContext(string identifier, object value) to attach metadata to an experiment. This data is stored in an internal dictionary and is accessible via the Contexts property on the Result object during publishing.

    public IUser GetUserByName(string userName)
    {
        return Scientist.Science<IUser>("get-user-by-name", experiment =>
        {
            experiment.AddContext("username", userName);
    
            experiment.Use(() => FindUser(userName));
            experiment.Try(() => GetUser(userName));
        });
    }
    
    // Accessing context in a publisher:
    public class MyResultPublisher : IResultPublisher
    {
        public Task Publish<T, TClean>(Result<T, TClean> result)
        {
            foreach (var kvp in result.Contexts)
            {
                Console.WriteLine($"Key: {kvp.Key}, Value: {kvp.Value}");
            }
            return Task.FromResult(0);
        }
    }
  8. Use FireAndForgetResultPublisher for non-blocking publishing

    master

    To prevent result publishing from delaying your experiments, wrap your IResultPublisher implementation in a FireAndForgetResultPublisher. This delegates the publishing task to a separate thread.

    Scientist.ResultPublisher = new FireAndForgetResultPublisher(new MyResultPublisher(onPublisherException));
  9. Run candidates in parallel with ScienceAsync

    master

    By default, Scientist runs tasks synchronously. To prevent experiments from doubling the latency of your method calls, use Scientist.ScienceAsync<T>(name, concurrencyLimit, callback) to run candidates in parallel. This ensures the total time taken is roughly the time of the slowest task.

    await Scientist.ScienceAsync<int>(
    	"ExperimentName",
    	3, // number of tasks to run concurrently 
    	experiment => {
            experiment.Use(async () => await StartRunningSomething(myData));
            experiment.Try(async () => await RunAtTheSameTimeAsTheControlMethod(myData));
            experiment.Try(async () => await AlsoRunThisConcurrently(myData));
    	});
  10. Use FireAndForgetResultPublisher to avoid publishing delays

    master
    In version 2.0.0 and later, you can use FireAndForgetResultPublisher to wrap an existing IResultPublisher. This decorator delegates the publishing of experiment results to a separate thread, which helps prevent publishing delays from impacting the performance of the experiments being run.
  11. Clean up result values with Clean

    master

    To avoid storing large or complex objects in your results, use experiment.Clean(selector) to extract a specific piece of data (like an ID or a string) from the observed value. The cleaned value is then available in result.Control.CleanedValue and result.Candidate.CleanedValue.

    public IUser GetUserByEmail(string emailAddress)
    {
        return Scientist.Science<IUser, string>("get-user-by-email", experiment =>
        {
            experiment.Use(() => OldApi.FindUserByEmail(emailAddress));
            experiment.Try(() => NewApi.GetUserByEmail(emailAddress));
            
            experiment.Clean(user => user.Login);
        });
    }
    
    // In the publisher:
    // result.Control.Value = <IUser object>
    // result.Control.CleanedValue = "user name"