Bug Fixes

This is due to when you disable access permission on the localisation/country class, it also disables the country function which is used in the ajax calls to obtain the county zones.
This is of course a design flaw, a bug if you will.
solutions:
1) Add localization/country to the ignore list in admin/controller/startup/permission.php
2) Move the country function out of that class.
3) Leave the access permission granted, you can still refuse permission on modify
I would suggest option 1.
Default OC does not call the gc() function and if it did, it compares timestamp with datetime which will never match.
Unknown why a datetime is used and not a timestamp in the database, now it is like talking via an interpreter when you speak eachother's language.
Replace the system/library/session/db.php file with this:

db = $registry->get('db');
		$this->expire = ini_get('session.gc_maxlifetime') ?? 3600;
		
		// session garbage collection using php.ini, defaults to 1% probability
		$gc_probability = ini_get('session.gc_probability') ?? 1;
		$gc_divisor = ini_get('session.gc_divisor') ?? 100;
		if ((rand() % $gc_divisor) < $gc_probability) $this->gc();
	}
	
	public function read($session_id) {
		$query = $this->db->query("SELECT data FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		if ($query->num_rows) return json_decode($query->row['data'], true);
		else return false;
	}
	
	public function write($session_id, $data) {
		if ($session_id) $this->db->query("REPLACE INTO " . DB_PREFIX . "session SET session_id = '" . $this->db->escape($session_id) . "', data = '" . $this->db->escape(json_encode($data)) . "', expire = '" . $this->db->escape(date('Y-m-d H:i:s', time() + $this->expire)) . "'");
		return true;
	}
	
	public function destroy($session_id) {
		$this->db->query("DELETE FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		return true;
	}
	
	public function gc() {
		$dt = date('Y-m-d H:i:s',((int)time()));
		$this->db->query("DELETE FROM " . DB_PREFIX . "session WHERE expire < '".$dt."'");
	}
	
	public function session_active ($session_id) {
		$query = $this->db->query("SELECT session_id FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		if (!$query->num_rows) return false;
		return true;
	}
}
catalog/controller/common/language.php

load->language('common/language');
		$data['action'] = $this->url->link('common/language/language', '', $this->request->server['HTTPS']);
		$data['code'] = $this->session->data['language'];
		$this->load->model('localisation/language');
		$data['languages'] = array();
		$results = $this->model_localisation_language->getLanguages();
		
		// save current language id
		$language_id_save = $this->config->get('config_language_id');
		
		foreach ($results as $result) {
			if ($result['status']) {
				$url = '';
				// determine redirect url for language
				$this->config->set('config_language_id',$result['language_id']);
				$this->session->data['language'] = $result['code'];
				if (!isset($this->request->get['route'])) {
					$url_data = $this->request->get;
					unset($url_data['_route_']);
					
					if ($url_data) $url = '&' . urldecode(http_build_query($url_data, '', '&'));
					$redirect = $this->url->link('common/home', $url, $this->request->server['HTTPS']);
				} else {
					$url_data = $this->request->get;
					unset($url_data['_route_']);
					$route = $url_data['route'];
					unset($url_data['route']);
					if ($url_data) $url = '&' . urldecode(http_build_query($url_data, '', '&'));
					$redirect = $this->url->link($route, $url, $this->request->server['HTTPS']);
				}
				$data['languages'][] = array(
					'name' => $result['name'],
					'code' => $result['code'],
					'href' => $redirect
				);
			}
		}
		// restore current language id
		$this->session->data['language'] = $data['code'];
		$this->config->set('config_language_id',$language_id_save);
		return $this->load->view('common/language', $data);
	}
	
	public function language() {
		$this->load->model('localisation/language');
		$languages = $this->model_localisation_language->getLanguages();
		$url = '';
		if (isset($this->request->post['code']) && array_key_exists($this->request->post['code'],$languages) && $languages[$this->request->post['code']]['status']) {
			// set the requested language
			$this->session->data['language'] = $this->request->post['code'];
			// use the correct redirect
			$this->response->redirect($this->request->post['redirect_'.$this->request->post['code']]);
		} else {
			$this->response->redirect($this->url->link('common/home', $url, $this->request->server['HTTPS']));
		}
	}
}
1) get rid of the cart merging logic in the construct function in system/library/cart/cart.php
remove this part:
		if ($this->customer->getId()) {
			// We want to change the session ID on all the old items in the customers cart
			$this->db->query("UPDATE " . DB_PREFIX . "cart SET session_id = '" . $this->db->escape($this->session->getId()) . "' WHERE api_id = '0' AND customer_id = '" . (int)$this->customer->getId() . "'");

			// Once the customer is logged in we want to update the customers cart
			$cart_query = $this->db->query("SELECT * FROM " . DB_PREFIX . "cart WHERE api_id = '0' AND customer_id = '0' AND session_id = '" . $this->db->escape($this->session->getId()) . "'");

			foreach ($cart_query->rows as $cart) {
				$this->db->query("DELETE FROM " . DB_PREFIX . "cart WHERE cart_id = '" . (int)$cart['cart_id'] . "'");

				// The advantage of using $this->add is that it will check if the products already exist and increaser the quantity if necessary.
				$this->add($cart['product_id'], $cart['quantity'], json_decode($cart['option']), $cart['recurring_id']);
			}
		}
2) add these functions which do the same but will be called only when needed:
	public function renew_session_id_old_items ($customer_id) {
		// We want to change the session ID on all the old items in the customers cart
		$sql = "UPDATE " . DB_PREFIX . "cart SET session_id = '" . $this->db->escape($this->session->getId()) . "' WHERE api_id = '0' AND customer_id = '" . (int)$customer_id . "'";
		$this->db->query($sql);
	}
	
	public function transfer_items_to_new_customer ($customer_id) {
		// Once the customer is logged in we want to merge the current guest items with the customer cart and empty the guest cart
		$sql = "SELECT * FROM " . DB_PREFIX . "cart WHERE api_id = '0' AND customer_id = '0' AND session_id = '" . $this->db->escape($this->session->getId()) . "'";
		$cart_save_query = $this->db->query($sql);
		foreach ($cart_save_query->rows as $cart) {
			$this->add($cart['product_id'], $cart['quantity'], json_decode($cart['option']), $cart['recurring_id']);
			$sql = "DELETE FROM " . DB_PREFIX . "cart WHERE cart_id = '" . (int)$cart['cart_id'] . "'";
			$this->db->query($sql);
		}
	}
3) call these functions in register and login controllers
2 function calls for login, giving the current session id to possible old customer items and merging the current guest items
1 function call for register as there cannot be any old customer items so just merging the current guest items

in catalog/controller/checkout/login.php and catalog/controller/account/login.php
just after:
unset($this->session->data['guest']);
add:
			// change[662] giving old cart items of the customer the current session id
			$this->cart->renew_session_id_old_items ($this->customer->getId());
			// change[662] assigning the current guest cart items to the customer
			$this->cart->transfer_items_to_new_customer ($this->customer->getId());
in catalog/controller/checkout/register.php and catalog/controller/account/register.php
just after:
unset($this->session->data['guest']);
add:
			// change[662] assigning the current guest cart items to the customer
			$this->cart->transfer_items_to_new_customer ($this->customer->getId());
That is all.

Improvements

1) create a static html page with a nice error message.
2) add to top of your index.php:

register_shutdown_function("shutdownHandler");
function shutdownHandler() {
         $lasterror = error_get_last();
         $error = '';
         switch ($lasterror['type']) {
                case E_ERROR:			$error = 'ERROR';break;
                case E_CORE_ERROR:		$error = 'CORE_ERROR';break;
                case E_COMPILE_ERROR:		$error = 'COMPILE_ERROR';break;
                case E_USER_ERROR:		$error = 'USER_ERROR';break;
                case E_RECOVERABLE_ERROR:	$error = 'RECOVERABLE_ERROR';break;
                case E_PARSE:			$error = 'PARSE';
                }
         if ($error != '') {
            error_log($error.': Showing static problem page to '.$_SERVER['REMOTE_ADDR']);
            ob_start();
            include('PATH_TO_YOUR_STATIC_HTML_PAGE');
            $problem_page = ob_get_clean();
            http_response_code(503);
            header('Retry-After: 300');
            echo $problem_page;
            exit();
         }
}
This can only happen when you share a session_id.
Under normal, proper site operations (^), that can only happen when you are issueing/using the same session_id while that session_id is already given.

Chances of that are very very small but not zero.

The change is increased when:
1) you have a huge backlog of used session records in your database (which is common in default OC) or file system. Remember, bots get a new session id on every request so that db table can fill pretty fast if not cleaned properly.
2) hackers who send requests with random/edited session cookies in the hope one is active, very rare, with virtually no chance but very difficult to counter (*).
3) Users who never close their browser and thus retain their session_id indefinitely which now may have been issued to someone else or some "remember me" implementation where the session cookie is no longer a session cookie but has a defined lifetime (*).

(*) many sites (incl OC) simply use the session_id presented in the cookie even if that session_id was not issued by the server nor whether that session is actually active on the server. As long as it is a valid format, that session_id is used.
(^) excludes unstable/experimental one-page checkout implementations

Item 1. you can counter by:
a) making sure your session garbage collection works properly which is the preferred method with the least performance impact.
b) checking whether a new session_id is already active before issueing/using it which adds extra effort, be it a minute one.

a)
For those sites we use the database for session storage better replace the file system/library/session/db.php
db = $registry->get('db');
		$this->expire = ini_get('session.gc_maxlifetime') ?? 3600;
		
		// session garbage collection using php.ini, defaults to 1% probability
		$gc_probability = ini_get('session.gc_probability') ?? 1;
		$gc_divisor = ini_get('session.gc_divisor') ?? 100;
		if ((rand() % $gc_divisor) < $gc_probability) $this->gc();
	}
	
	public function read($session_id) {
		$query = $this->db->query("SELECT data FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		if ($query->num_rows) return json_decode($query->row['data'], true);
		else return false;
	}
	
	public function write($session_id, $data) {
		if ($session_id) $this->db->query("REPLACE INTO " . DB_PREFIX . "session SET session_id = '" . $this->db->escape($session_id) . "', data = '" . $this->db->escape(json_encode($data)) . "', expire = '" . $this->db->escape(date('Y-m-d H:i:s', time() + $this->expire)) . "'");
		return true;
	}
	
	public function destroy($session_id) {
		$this->db->query("DELETE FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		return true;
	}
	
	public function gc() {
		$dt = date('Y-m-d H:i:s',((int)time()));
		$this->db->query("DELETE FROM " . DB_PREFIX . "session WHERE expire < '".$dt."'");
	}
	
	public function session_active ($session_id) {
		$query = $this->db->query("SELECT session_id FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		if (!$query->num_rows) return false;
		return true;
	}
}


That should take better care of the session garbage collection, the default gc() function is crap as it compares a timestamp with a date-time value apart from the fact that it is never called. The file storage class already has a __destruct() function and works fine.

b)
we changed the start function in system/library/session.php to:
public function start($session_id = false, $no_clash = true) {
		if ($session_id) {
			// determine if session id is valid format
			if (!preg_match('/^[a-zA-Z0-9,\-]{22,52}$/', $session_id)) {
				error_log('Error: invalid session ID given:'.$session_id.' by '.$_SERVER['REMOTE_ADDR'].', issueing a new one...');
				$session_id = false;
			}
		}
		if (!$session_id) {
			// issue new session id
			$session_id_active = true;
			while ($session_id_active) {
				// generate a new session id
				if (function_exists('random_bytes')) $session_id = substr(bin2hex(random_bytes(32)), 0, 32);
				else $session_id = substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 32);
				// exit loop if no_clash is false or session id not active
				if (!$no_clash) $session_id_active = false;
				elseif (!$this->adaptor->session_active($session_id)) $session_id_active = false;
			}
		}
		$this->session_id = $session_id;
		$this->data = $this->adaptor->read($session_id);
		return $session_id;
	}

We added to system/library/session/db.php
	public function session_active ($session_id) {
		// determine if session_id is present in the session table
		$this->db->query("SELECT session_id FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($session_id) . "'");
		if (!$query->num_rows) return false;
		return true;
	}

We added to system/library/session/file.php
	public function session_active ($session_id) {
		// determine if session_id is present in the file system
		$file = DIR_SESSION . 'sess_' . basename($session_id);
		clearstatcache(true, $file);
		if (!file_exists($file)) return false;
		return true;
	}

These function will check whether a newly generated session_id is already active server-side and generate a new one if it is.
Both for db and file stored sessions. You can toggle that check with the $no_clash parameter.

In addition, as stated (*), OC will use any session_id given to it via the cookie as long as it has the right format, which is not good.
We check whether the given session_id actually is active server-side or that given session_id is ignored.

catalog/controller/startup/session.php
change:
			if (isset($_COOKIE[$this->config->get('session_name')])) {
				$session_id = $_COOKIE[$this->config->get('session_name')];
			} else {
				$session_id = '';
			}
			$this->session->start($session_id);

to:
		$session_id = '';
			if (!empty($_COOKIE[$this->config->get('session_name')])) {
				if (SESSION_VERIFY) {
					if ($this->config->get('session_engine') == 'db') {
						$query = $this->db->query("SELECT session_id FROM " . DB_PREFIX . "session WHERE session_id = '" . $this->db->escape($_COOKIE[$this->config->get('session_name')]) . "'");
						if ($query->num_rows) $session_id = $_COOKIE[$this->config->get('session_name')];
					} else {
						$session_file = DIR_SESSION.'sess_'.$_COOKIE[$this->config->get('session_name')];
						if (is_file($session_file))	$session_id = $_COOKIE[$this->config->get('session_name')];
					}
				} else {
					$session_id = $_COOKIE[$this->config->get('session_name')];
				}
			}
			$this->session->start($session_id,SESSION_NOCLASH);

system/framework.php
change:
	if (isset($_COOKIE[$config->get('session_name')])) {
		$session_id = $_COOKIE[$config->get('session_name')];
	} else {
		$session_id = '';
	}
	$this->session->start($session_id);

to:
	$session_id = false;
	if (isset($_COOKIE[$config->get('session_name')])) {
		if (SESSION_VERIFY) {
			if ($config->get('session_engine') == 'db') {
				$query = $db->query("SELECT session_id FROM " . DB_PREFIX . "session WHERE session_id = '" . $db->escape($_COOKIE[$config->get('session_name')]) . "'");
				if ($query->num_rows) $session_id = $_COOKIE[$config->get('session_name')];
			} else {
				$session_file = DIR_SESSION.'sess_'.$_COOKIE[$config->get('session_name')];
				if (is_file($session_file))	$session_id = $_COOKIE[$config->get('session_name')];
			}
		} else {
			$session_id = $_COOKIE[$config->get('session_name')];
		}
	}
	$this->session->start($session_id,SESSION_NOCLASH);

change:
// Database
if ($config->get('db_autostart')) {
	$registry->set('db', new DB($config->get('db_engine'), $config->get('db_hostname'), $config->get('db_username'), $config->get('db_password'), $config->get('db_database'), $config->get('db_port')));
}

to:
// Database
if ($config->get('db_autostart')) {
	$db = new DB($config->get('db_engine'), $config->get('db_hostname'), $config->get('db_username'), $config->get('db_password'), $config->get('db_database'), $config->get('db_port'));
	$registry->set('db', $db);
}

to be able to access the database from here.
(Turning verify on in framework and session controller makes the format check redundant as an invalid formatted session id would never be found in the database or file system)

define the switches in config
config.php
define('SESSION_VERIFY',	true); // verify if the given session id exists server side
define('SESSION_NOCLASH',	false); // prevent issueing new session id which already exists

As said, under proper operations, the chance of clashing sessions is very small but not zero and when it does happen, the impact/embarassment is rather large.
So make your own choice on whether the yield is worth the extra effort.

as mentioned, bots do not retain sessions and as such they invoke a new session generation on each request.
That is why the vast majority of your sessions in your session table or file system are for bots (you thought you had a lot of customers).

Since bots have no use for them beyond the lifetime of the script, those sessions can be removed immediately after script end.
(in the olden days it was common practice to not issue sessions to bots in the first place but that is not a good practice as the script itself and the content it produces relies on variables stored and used in the session during execution)

We do this by identifying public bots (those identifying themselves as such via the user agent, assuming everybody knows how to do that) and direct the session class to delete the session upon final session close (script end).

In catalog/controller/startup/startup.php we identify if the request comes from a public bot.
If it is, we set a session variable:
$this->data['force_remove_session'] = false;
if ($bot) $this->data['force_remove_session'] = true;

in system/library/session.php we change the shutdown function.
change:
	public function close() {
		$this->adaptor->write($this->session_id, $this->data);
	}

to:
	public function close() {
		$this->adaptor->write($this->session_id, $this->data);
		if (isset($this->data['force_remove_session']) && $this->data['force_remove_session']) {
			$this->adaptor->destroy($this->session_id);
		}
	}

That way any session given to a bot is deleted after the script for that request ends and that will reduce the session table/directory to a large extend not waiting for the normal session garbage collection to kick in.

you can of course also just increase the character count of the session id from 32 to say 50.
That also reduces the chance of clashes but it still is not zero (just like the lottery) and as such it is more like an arms-race.
When you resize images, as Opencart does, they become more blurry.
Therefore, you need to sharpen them before saving.

catalog/model/tool/image.php
change:
$image = new Image(DIR_IMAGE . $image_old);
$image->resize($width, $height);
$image->save(DIR_IMAGE . $image_new);
to:
$image = new Image(DIR_IMAGE . $image_old);
$image->resize($width, $height);
$image->imagesharpen_precise();
$image->save(DIR_IMAGE . $image_new);
system/library/image.php
add:
public function imagesharpen_precise() {
	$height = imagesy($this->image);
	$width  = imagesx($this->image);
	$rs = array();
	$gs = array();
	$bs = array();
	for ($y = 0; $y < $height; ++$y) {
		for ($x = 0; $x < $width; ++$x) {
			$rgb = imagecolorat($this->image, $x, $y);
			$rs[$y][$x] = $rgb >> 0x10;
			$gs[$y][$x] = $rgb >> 0x08 & 0xFF;
			$bs[$y][$x] = $rgb         & 0xFF;
		}
	}
	$height--;
	$width--;
	for ($y = 1; $y < $height; ++$y) {
		$rd = $rs[$y][0];
		$gd = $gs[$y][0];
		$bd = $bs[$y][0];
		$yd = $y - 1;
		$yi = $y + 1;
		for ($x = 1; $x < $width; ++$x) {
			$r = -($rs[$yd][$x] + $rs[$yi][$x] + $rd + $rs[$y][$x + 1]) / 4;
			$g = -($gs[$yd][$x] + $gs[$yi][$x] + $gd + $gs[$y][$x + 1]) / 4;
			$b = -($bs[$yd][$x] + $bs[$yi][$x] + $bd + $bs[$y][$x + 1]) / 4;
			$r += 2 * $rd = $rs[$y][$x];
			$g += 2 * $gd = $gs[$y][$x];
			$b += 2 * $bd = $bs[$y][$x];
			if ($r < 0) $r = 0;
			elseif ($r > 255) $r = 255;
			if ($g < 0) $g = 0;
			elseif ($g > 255) $g = 255;
			if ($b < 0) $b = 0;
			elseif ($b > 255) $b = 255;
			imagesetpixel($this->image, $x, $y, $r << 0x10 | $g << 0x08 | $b);
		}
	}
	$rs = null;
	$gs = null;
	$bs = null;
	unset($rs);
	unset($gs);
	unset($bs);
}


public function imagesharpen($matrix = 1) {
	if ($matrix == 1) {
		$sharpen = array(
			array(0.0, -1.0, 0.0),
			array(-1.0, 5.0, -1.0),
			array(0.0, -1.0, 0.0)
		);
	} else {
		$sharpen = array(
			array(-1, -1,  -1),
			array(-1, 16, -1),
			array(-1, -1,  -1),
		);
	}
	$divisor = array_sum(array_map('array_sum', $sharpen));
	$offset = 0;
	imageconvolution($this->image, $sharpen, $divisor, $offset);
}