Exclude expired sticky posts

EDIT

Here is another approach to the one in my ORIGINAL ANSWER. Instead of the need to alter the main query and $posts global to remove expired stickies, why not remove the posts entirely from being sticky, ie, unstick the posts which has expired. This way, we do not need to check for expired stickies and remove them on every page load.

What we will do is to run a scheduled event daily (or twice daily, whatever suits you the best) which will look for expired stickies and remove them from the array of sticky posts.

NOTE:, before you run this code, make sure that you would want to remove stickies from the array saved in db. Once this function is run, any exired post will not be available in get_option( 'sticky_posts' ) anymore)

// Set our hook and function to remove sticky posts
add_action( 'unstick_expired_posts', function ()
{
    // Get the sticky posts array
    $stickies = get_option( 'sticky_posts' );

    // return if we do not have stickies
    if ( !$stickies )
        return;

    // Get all expired stickies, just the id's
    $args = [
        'posts_per_page' => count( $stickies ),
        'post__in'       => $stickies,
        'fields'         => 'ids',
        'meta_query'     => [ // Taken unchanged from OP
            'relation' => 'OR', 
            [ 
                'key'     =>'ed_expiry', 
                'compare' => 'NOT EXISTS' 
            ], 
            [ 
                'key'     => 'ed_expiry', 
                'value'   => current_time( 'mysql' ), 
                'type'    => 'DATETIME', 
                'compare' => '>',
            ]
        ],
        // Add any additional arguments to get stickies by
    ];
    // We will use get_posts because it already exclude sticky posts and skips pagination
    $expired_stickies = get_posts( $args );

    // Check if we have posts, if not, return
    if ( !$expired_stickies )
        return;

    /**
     * We will now compare the $q and $stickies array. We will use array_diff() 
     * to return all stickies that does not appear in $q
     */
    $active_stickies = array_diff( $stickies, $expired_stickies );

    // We will now update the sticky posts option in db with $active_stickies
    update_option( 'sticky_posts', $active_stickies );
} 

We can now schedule the event to run daily, so once a day we will check if there are any expired sticky posts, and if there is, we will remove them from the sticky posts option.

// Add function to register event to wp
add_action( 'wp', 'register_daily_expired_sticky_post_delete_event');
function register_daily_expired_sticky_post_delete_event() {
    // Make sure this event hasn't been scheduled
    if( !wp_next_scheduled( 'expired_post_delete' ) ) {
        // Schedule the event
        wp_schedule_event( time(), 'daily', 'unstick_expired_posts' );
    }
}

As I said, you can set the event to run more frequent or less frequent by changing daily to suit your needs.

If you really need to-the-second deletion of stickies, you can incorporate the function to run inside your pre_get_posts action inside an is_home() conditional tag

ORIGINAL ANSWER

Sticky posts, IMHO, are sometimes just a pain in the butt when you start with displaying posts according to conditions, and this is the case in this situation. The best here would be is to remove sticky posts from the main query, and then adding them back into the $posts property of the main query object via the the_posts filter

First, lets remove the sticky posts completely from the main query through our pre_get_posts action

add_action( 'pre_get_posts', function ( $q )
{
     if (    !is_admin() // Target only the front end
          && $q->is_main_query() // Targets only the main query
          && !$q->is_search() 
          && !$q->is_singular() 
          && !$q->is_date() 
     ) { 
        $meta_query = [['Your meta query conditions']],
        $q->set( 'meta_query', $meta_query );
        $q->set( 'ignore_sticky_posts', 1 ); // Remove sticky posts
        $q->set( 'post__not_in', get_option( 'sticky_posts' ) ); // Remove stickies from the loop to avois duplicates
    }
});

You would see no more sticky posts. Just remember, I have just given you a backbone, you should add all your other code into mine or add mine to yours 😉

Now we need to add the stickies back, but we will do that through the the_posts filter

add_filter( 'the_posts', function ( $posts, $q )
{
    if (    !is_admin() // Target only the front end
         && $q->is_main_query() // Targets only the main query
         && !$q->is_home() // only add stickies on the homepage 
         && !$q->is_paged() // Only target the first paged page
    ) { 
        // Do this only if we have stickies to avoid all posts returned regadless
        // Get the array sticky posts (ids) from the db
        $sticky_array = get_option( 'sticky_posts' );
        if ( $sticky_array ) {
            // Query our sticky posts according to condition
            $args = [
                'posts_per_page'      => -1, // Query all stickies
                'ignore_sticky posts' => 1, // Strange but true, exclude stickies
                'post__in'            => $sticky_array, // Query all posts from the $sticky_array
                'meta_query'          => [[]], // Add your meta query conditions as in OP
                // Add any other conditions as you need them
            ];
            $sticky_posts = new WP_Query( $args );

            // Now we will add our sticky posts in front of the our other posts
            $posts = array_merge( $sticky_posts->posts, $posts );
         }
    }
    return $posts;
});